From eec194491efa44994849a111be9aab737309f6af Mon Sep 17 00:00:00 2001 From: Vankka Date: Sat, 13 Apr 2024 23:38:02 +0300 Subject: [PATCH] AbstractSyncModule --- .../api/module/type/PunishmentModule.java | 26 +- .../discordsrv/api/punishment/Punishment.java | 30 + .../bukkit/ban/BukkitBanModule.java | 9 +- .../integration/EssentialsXIntegration.java | 3 +- .../discordsrv/common/AbstractDiscordSRV.java | 2 + .../common/bansync/BanSyncModule.java | 282 +++++-- .../common/bansync/enums/BanSyncResult.java | 28 +- .../combined/commands/ResyncCommand.java | 69 +- .../common/config/main/BanSyncConfig.java | 55 ++ .../common/config/main/GroupSyncConfig.java | 85 +- .../common/config/main/MainConfig.java | 3 + .../main/generic/AbstractSyncConfig.java | 72 ++ .../connection/jda/JDAConnectionManager.java | 3 + .../common/groupsync/GroupSyncModule.java | 789 ++++++------------ .../common/groupsync/GroupSyncSummary.java | 12 +- .../groupsync/enums/GroupSyncResult.java | 16 +- .../common/sync/AbstractSyncModule.java | 296 +++++++ .../discordsrv/common/sync/ISyncResult.java | 7 + .../com/discordsrv/common/sync/SyncFail.java | 19 + .../common/sync/enums/SyncResults.java | 43 + 20 files changed, 1099 insertions(+), 750 deletions(-) create mode 100644 api/src/main/java/com/discordsrv/api/punishment/Punishment.java create mode 100644 common/src/main/java/com/discordsrv/common/config/main/BanSyncConfig.java create mode 100644 common/src/main/java/com/discordsrv/common/config/main/generic/AbstractSyncConfig.java create mode 100644 common/src/main/java/com/discordsrv/common/sync/AbstractSyncModule.java create mode 100644 common/src/main/java/com/discordsrv/common/sync/ISyncResult.java create mode 100644 common/src/main/java/com/discordsrv/common/sync/SyncFail.java create mode 100644 common/src/main/java/com/discordsrv/common/sync/enums/SyncResults.java diff --git a/api/src/main/java/com/discordsrv/api/module/type/PunishmentModule.java b/api/src/main/java/com/discordsrv/api/module/type/PunishmentModule.java index a2cb3f63..de9b9f3d 100644 --- a/api/src/main/java/com/discordsrv/api/module/type/PunishmentModule.java +++ b/api/src/main/java/com/discordsrv/api/module/type/PunishmentModule.java @@ -1,6 +1,7 @@ package com.discordsrv.api.module.type; import com.discordsrv.api.module.Module; +import com.discordsrv.api.punishment.Punishment; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -21,29 +22,4 @@ public interface PunishmentModule extends Module { CompletableFuture addMute(@NotNull UUID playerUUID, @Nullable Instant until, @Nullable String reason, @NotNull String punisher); CompletableFuture removeMute(@NotNull UUID playerUUID); } - - class Punishment { - - private final Instant until; - private final String reason; - private final String punisher; - - public Punishment(@Nullable Instant until, @Nullable String reason, @Nullable String punisher) { - this.until = until; - this.reason = reason; - this.punisher = punisher; - } - - public Instant until() { - return until; - } - - public String reason() { - return reason; - } - - public String punisher() { - return punisher; - } - } } diff --git a/api/src/main/java/com/discordsrv/api/punishment/Punishment.java b/api/src/main/java/com/discordsrv/api/punishment/Punishment.java new file mode 100644 index 00000000..a9713078 --- /dev/null +++ b/api/src/main/java/com/discordsrv/api/punishment/Punishment.java @@ -0,0 +1,30 @@ +package com.discordsrv.api.punishment; + +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public class Punishment { + + private final Instant until; + private final String reason; + private final String punisher; + + public Punishment(@Nullable Instant until, @Nullable String reason, @Nullable String punisher) { + this.until = until; + this.reason = reason; + this.punisher = punisher; + } + + public Instant until() { + return until; + } + + public String reason() { + return reason; + } + + public String punisher() { + return punisher; + } +} diff --git a/bukkit/src/main/java/com/discordsrv/bukkit/ban/BukkitBanModule.java b/bukkit/src/main/java/com/discordsrv/bukkit/ban/BukkitBanModule.java index 55767402..4c7c0f3a 100644 --- a/bukkit/src/main/java/com/discordsrv/bukkit/ban/BukkitBanModule.java +++ b/bukkit/src/main/java/com/discordsrv/bukkit/ban/BukkitBanModule.java @@ -1,6 +1,7 @@ package com.discordsrv.bukkit.ban; import com.discordsrv.api.module.type.PunishmentModule; +import com.discordsrv.api.punishment.Punishment; import com.discordsrv.bukkit.BukkitDiscordSRV; import com.discordsrv.common.bansync.BanSyncModule; import com.discordsrv.common.module.type.AbstractModule; @@ -34,13 +35,13 @@ public class BukkitBanModule extends AbstractModule implements BanSyncModule module = discordSRV.getModule(BanSyncModule.class); if (module != null) { - getBan(player.getUniqueId()).thenApply(Punishment::reason) - .whenComplete((reason, t) -> module.notifyBanned(discordSRV.playerProvider().player(player), reason)); + getBan(player.getUniqueId()) + .whenComplete((punishment, t) -> module.notifyBanned(discordSRV.playerProvider().player(player), punishment)); } } @Override - public CompletableFuture getBan(@NotNull UUID playerUUID) { + public CompletableFuture getBan(@NotNull UUID playerUUID) { CompletableFuture entryFuture; if (PaperBanList.IS_AVAILABLE) { entryFuture = CompletableFuture.completedFuture(PaperBanList.getBanEntry(discordSRV.server(), playerUUID)); @@ -52,7 +53,7 @@ public class BukkitBanModule extends AbstractModule implements return entryFuture.thenApply(ban -> { Date expiration = ban.getExpiration(); - return new PunishmentModule.Punishment(expiration != null ? expiration.toInstant() : null, ban.getReason(), ban.getSource()); + return new Punishment(expiration != null ? expiration.toInstant() : null, ban.getReason(), ban.getSource()); }); } diff --git a/bukkit/src/main/java/com/discordsrv/bukkit/integration/EssentialsXIntegration.java b/bukkit/src/main/java/com/discordsrv/bukkit/integration/EssentialsXIntegration.java index c657e47e..56ef6213 100644 --- a/bukkit/src/main/java/com/discordsrv/bukkit/integration/EssentialsXIntegration.java +++ b/bukkit/src/main/java/com/discordsrv/bukkit/integration/EssentialsXIntegration.java @@ -8,6 +8,7 @@ import com.discordsrv.api.event.events.message.receive.game.GameChatMessageRecei import com.discordsrv.api.module.type.NicknameModule; import com.discordsrv.api.module.type.PunishmentModule; import com.discordsrv.api.player.DiscordSRVPlayer; +import com.discordsrv.api.punishment.Punishment; import com.discordsrv.bukkit.BukkitDiscordSRV; import com.discordsrv.bukkit.player.BukkitPlayer; import com.discordsrv.common.component.util.ComponentUtil; @@ -75,7 +76,7 @@ public class EssentialsXIntegration } @Override - public CompletableFuture getMute(@NotNull UUID playerUUID) { + public CompletableFuture getMute(@NotNull UUID playerUUID) { return getUser(playerUUID).thenApply(user -> new Punishment(Instant.ofEpochMilli(user.getMuteTimeout()), user.getMuteReason(), null)); } diff --git a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java index 3fd84894..086b4771 100644 --- a/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java +++ b/common/src/main/java/com/discordsrv/common/AbstractDiscordSRV.java @@ -24,6 +24,7 @@ import com.discordsrv.api.event.events.lifecycle.DiscordSRVReloadedEvent; import com.discordsrv.api.event.events.lifecycle.DiscordSRVShuttingDownEvent; import com.discordsrv.api.module.Module; import com.discordsrv.common.api.util.ApiInstanceUtil; +import com.discordsrv.common.bansync.BanSyncModule; import com.discordsrv.common.bootstrap.IBootstrap; import com.discordsrv.common.channel.ChannelConfigHelper; import com.discordsrv.common.channel.ChannelLockingModule; @@ -565,6 +566,7 @@ public abstract class AbstractDiscordSRV< placeholderService().addGlobalContext(UUIDUtil.class); // Modules + registerModule(BanSyncModule::new); registerModule(ConsoleModule::new); registerModule(ChannelLockingModule::new); registerModule(TimedUpdaterModule::new); diff --git a/common/src/main/java/com/discordsrv/common/bansync/BanSyncModule.java b/common/src/main/java/com/discordsrv/common/bansync/BanSyncModule.java index 35454bcf..a741000b 100644 --- a/common/src/main/java/com/discordsrv/common/bansync/BanSyncModule.java +++ b/common/src/main/java/com/discordsrv/common/bansync/BanSyncModule.java @@ -1,34 +1,69 @@ package com.discordsrv.common.bansync; +import com.discordsrv.api.discord.connection.details.DiscordGatewayIntent; import com.discordsrv.api.event.bus.Subscribe; import com.discordsrv.api.event.events.linking.AccountLinkedEvent; -import com.discordsrv.api.event.events.linking.AccountUnlinkedEvent; import com.discordsrv.api.module.type.PunishmentModule; +import com.discordsrv.api.punishment.Punishment; import com.discordsrv.common.DiscordSRV; import com.discordsrv.common.bansync.enums.BanSyncResult; +import com.discordsrv.common.config.main.BanSyncConfig; import com.discordsrv.common.event.events.player.PlayerConnectedEvent; import com.discordsrv.common.module.type.AbstractModule; import com.discordsrv.common.player.IPlayer; import com.discordsrv.common.profile.Profile; +import com.discordsrv.common.sync.enums.SyncDirection; +import com.discordsrv.common.sync.enums.SyncSide; import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.audit.ActionType; +import net.dv8tion.jda.api.audit.AuditLogEntry; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.UserSnowflake; +import net.dv8tion.jda.api.events.guild.GuildAuditLogEntryCreateEvent; import net.dv8tion.jda.api.events.guild.GuildBanEvent; import net.dv8tion.jda.api.events.guild.GuildUnbanEvent; import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.requests.ErrorResponse; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; public class BanSyncModule extends AbstractModule { + private final Map events = new ConcurrentHashMap<>(); + public BanSyncModule(DiscordSRV discordSRV) { super(discordSRV); } + @Override + public boolean isEnabled() { + if (discordSRV.config().banSync.serverId == 0) { + //return false; + } + + return super.isEnabled(); + } + + @Override + public @NotNull Collection requiredIntents() { + return Collections.singleton(DiscordGatewayIntent.GUILD_MODERATION); + } + + private Punishment punishment(Guild.Ban ban) { + return ban != null ? new Punishment(null, ban.getReason(), ban.getUser().getName()) : null; + } + private CompletableFuture lookupLinkedAccount(UUID player) { return discordSRV.profileManager().lookupProfile(player) .thenApply(Profile::userId); @@ -39,70 +74,203 @@ public class BanSyncModule extends AbstractModule { .thenApply(Profile::playerUUID); } - public void notifyBanned(IPlayer player, @Nullable String reason) { - playerBanChange(player.uniqueId(), true); + public void notifyBanned(IPlayer player, @Nullable Punishment punishment) { + playerBanChange(player.uniqueId(), true, punishment); } @Subscribe public void onPlayerConnected(PlayerConnectedEvent event) { - playerBanChange(event.player().uniqueId(), false); + playerBanChange(event.player().uniqueId(), false, null); + } + + private PunishmentEvent upsertEvent(long userId, boolean newState) { + return events.computeIfAbsent(userId, key -> new PunishmentEvent(userId, newState)); + } + + private class PunishmentEvent { + + private final long userId; + private final boolean newState; + private final Future future; + + public PunishmentEvent(long userId, boolean newState) { + this.userId = userId; + this.newState = newState; + + // Run in 5s if an audit log event doesn't arrive + this.future = discordSRV.scheduler().runLater(() -> applyPunishment(null), Duration.ofSeconds(5)); + } + + public void applyPunishment(@Nullable Punishment punishment) { + if (!future.cancel(false)) { + return; + } + + userBanChange(userId, newState, punishment); + } } @Subscribe public void onGuildBan(GuildBanEvent event) { - userBanChange(event.getUser().getIdLong(), true); + upsertEvent(event.getUser().getIdLong(), true); } @Subscribe public void onGuildUnban(GuildUnbanEvent event) { - userBanChange(event.getUser().getIdLong(), false); + upsertEvent(event.getUser().getIdLong(), false); + } + + @Subscribe + public void onGuildAuditLogEntryCreate(GuildAuditLogEntryCreateEvent event) { + AuditLogEntry entry = event.getEntry(); + ActionType actionType = entry.getType(); + if (actionType != ActionType.BAN && actionType != ActionType.UNBAN) { + return; + } + + long punisherId = entry.getUserIdLong(); + User punisher = event.getJDA().getUserById(punisherId); + String punishmentName = punisher != null ? punisher.getName() : Long.toUnsignedString(punisherId); + + Punishment punishment = new Punishment(null, entry.getReason(), punishmentName); + long bannedUserId = entry.getTargetIdLong(); + + // Apply punishments instantly when audit log events arrive. + if (actionType == ActionType.BAN) { + upsertEvent(bannedUserId, true).applyPunishment(punishment); + } else { + upsertEvent(bannedUserId, false).applyPunishment(punishment); + } } @Subscribe public void onAccountLinked(AccountLinkedEvent event) { - + BanSyncConfig config = discordSRV.config().banSync; + if (config.resyncUponLinking) { + resync(event.getPlayerUUID(), event.getUserId()); + } } - @Subscribe - public void onAccountUnlinked(AccountUnlinkedEvent event) { - + private CompletableFuture getBan(Guild guild, long userId) { + return guild.retrieveBan(UserSnowflake.fromId(userId)).submit().exceptionally(t -> { + if (t instanceof ErrorResponseException && ((ErrorResponseException) t).getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { + return null; + } + throw (RuntimeException) t; + }); } - private void playerBanChange(UUID player, boolean newState) { - lookupLinkedAccount(player).thenApply(userId -> { + public CompletableFuture resync(UUID playerUUID) { + return lookupLinkedAccount(playerUUID).thenCompose(userId -> { if (userId == null) { // Unlinked return null; } - // TODO: configurable reason format - return changeUserBanState(userId, newState, null); + return resync(playerUUID, userId); }); } - private CompletableFuture changeUserBanState(long userId, boolean newState, @Nullable String reason) { + public CompletableFuture resync(long userId) { + return lookupLinkedAccount(userId).thenCompose(playerUUID -> { + if (playerUUID == null) { + // Unlinked + return null; + } + + return resync(playerUUID, userId); + }); + } + + public CompletableFuture resync(UUID playerUUID, long userId) { + return doResync(playerUUID, userId).whenComplete((r, t) -> { + String label = playerUUID + ":" + Long.toUnsignedString(userId); + if (t != null) { + logger().error("Failed to update ban state for " + label, t); + } else { + logger().debug("Updated " + label + " ban state: " + r); + } + }); + } + + private CompletableFuture doResync(UUID playerUUID, long userId) { + BanSyncConfig config = discordSRV.config().banSync; + + SyncSide side = config.tieBreaker; + if (side == null) { + return CompletableFuture.completedFuture(BanSyncResult.INVALID_CONFIG); + } + + switch (side) { + case DISCORD: + JDA jda = discordSRV.jda(); + if (jda == null) { + return CompletableFuture.completedFuture(BanSyncResult.NO_DISCORD_CONNECTION); + } + + Guild guild = jda.getGuildById(config.serverId); + if (guild == null) { + // Server doesn't exist + return CompletableFuture.completedFuture(BanSyncResult.GUILD_DOESNT_EXIST); + } + + return getBan(guild, userId) + .thenCompose(ban -> changePlayerBanState(playerUUID, ban != null, punishment(ban))); + case MINECRAFT: + PunishmentModule.Bans bans = discordSRV.getModule(PunishmentModule.Bans.class); + if (bans == null) { + return CompletableFuture.completedFuture(BanSyncResult.NO_PUNISHMENT_INTEGRATION); + } + + return bans.getBan(playerUUID) + .thenCompose(punishment -> changeUserBanState(userId, punishment != null, punishment)); + default: + throw new IllegalStateException("Missing side " + side.name()); + } + } + + private void playerBanChange(UUID playerUUID, boolean newState, @Nullable Punishment punishment) { + lookupLinkedAccount(playerUUID).thenCompose(userId -> { + if (userId == null) { + // Unlinked + return null; + } + + return changeUserBanState(userId, newState, punishment).whenComplete((r, t) -> { + if (t != null) { + logger().error("Failed to update ban state for " + Long.toUnsignedString(userId), t); + } else { + logger().debug("Updated " + Long.toUnsignedString(userId) + " ban state: " + r); + } + }); + }); + } + + private CompletableFuture changeUserBanState(long userId, boolean newState, @Nullable Punishment punishment) { + BanSyncConfig config = discordSRV.config().banSync; + if (config.direction == SyncDirection.DISCORD_TO_MINECRAFT) { + return CompletableFuture.completedFuture(BanSyncResult.WRONG_DIRECTION); + } + JDA jda = discordSRV.jda(); if (jda == null) { return CompletableFuture.completedFuture(BanSyncResult.NO_DISCORD_CONNECTION); } - Guild guild = jda.getGuildById(0L); // TODO: config + Guild guild = jda.getGuildById(config.serverId); if (guild == null) { // Server doesn't exist return CompletableFuture.completedFuture(BanSyncResult.GUILD_DOESNT_EXIST); } UserSnowflake snowflake = UserSnowflake.fromId(userId); - return guild.retrieveBan(snowflake).submit().exceptionally(t -> { - if (t instanceof ErrorResponseException && ((ErrorResponseException) t).getErrorResponse() == ErrorResponse.UNKNOWN_BAN) { - return null; - } - throw (RuntimeException) t; - }).thenCompose(ban -> { + return getBan(guild, userId).thenCompose(ban -> { if (ban == null) { if (newState) { - // TODO: configurable deletion timeframe - return guild.ban(snowflake, 0, TimeUnit.MILLISECONDS).reason(reason).submit().thenApply(v -> BanSyncResult.BAN_USER); + return guild.ban(snowflake, config.discordMessageHoursToDelete, TimeUnit.HOURS) + .reason(discordSRV.placeholderService().replacePlaceholders(config.discordBanReasonFormat, punishment)) + .submit() + .thenApply(v -> BanSyncResult.BAN_USER); } else { // Already unbanned return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); @@ -112,51 +280,63 @@ public class BanSyncModule extends AbstractModule { // Already banned return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); } else { - return guild.unban(snowflake).reason(reason).submit().thenApply(v -> BanSyncResult.UNBAN_USER); + return guild.unban(snowflake) + .reason(discordSRV.placeholderService().replacePlaceholders(config.discordUnbanReasonFormat, punishment)) + .submit() + .thenApply(v -> BanSyncResult.UNBAN_USER); } } }); } - public void userBanChange(long userId, boolean newState) { - lookupLinkedAccount(userId).thenApply(playerUUID -> { + public void userBanChange(long userId, boolean newState, @Nullable Punishment punishment) { + lookupLinkedAccount(userId).thenCompose(playerUUID -> { if (playerUUID == null) { // Unlinked return null; } - // TODO: configurable reason format - return changePlayerBanState(playerUUID, newState, null); + return changePlayerBanState(playerUUID, newState, punishment).whenComplete((r, t) -> { + if (t != null) { + logger().error("Failed to update ban state for " + playerUUID, t); + } else { + logger().debug("Updated " + playerUUID + " ban state: " + r); + } + }); }); } - private CompletableFuture changePlayerBanState(UUID playerUUID, boolean newState, @Nullable String reason) { + private CompletableFuture changePlayerBanState(UUID playerUUID, boolean newState, @Nullable Punishment punishment) { + BanSyncConfig config = discordSRV.config().banSync; + if (config.direction == SyncDirection.MINECRAFT_TO_DISCORD) { + return CompletableFuture.completedFuture(BanSyncResult.WRONG_DIRECTION); + } + PunishmentModule.Bans bans = discordSRV.getModule(PunishmentModule.Bans.class); if (bans == null) { return CompletableFuture.completedFuture(BanSyncResult.NO_PUNISHMENT_INTEGRATION); } - return bans.getBan(playerUUID) - .thenCompose(punishment -> { - if (punishment == null) { - if (newState) { - return bans.addBan(playerUUID, null, reason, "DiscordSRV") - .thenApply(v -> BanSyncResult.BAN_PLAYER); - } else { - // Already unbanned - return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); - } - } else { - if (newState) { - // Already banned - return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); - } else { - return bans.removeBan(playerUUID).thenApply(v -> BanSyncResult.UNBAN_PLAYER); - } - } - }); + return bans.getBan(playerUUID).thenCompose(existingPunishment -> { + if (existingPunishment == null) { + if (newState) { + String reason = discordSRV.placeholderService().replacePlaceholders(config.gameBanReasonFormat, punishment); + String punisher = discordSRV.placeholderService().replacePlaceholders(config.gamePunisherFormat, punishment); + return bans.addBan(playerUUID, null, reason, punisher) + .thenApply(v -> BanSyncResult.BAN_PLAYER); + } else { + // Already unbanned + return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); + } + } else { + if (newState) { + // Already banned + return CompletableFuture.completedFuture(BanSyncResult.ALREADY_IN_SYNC); + } else { + return bans.removeBan(playerUUID).thenApply(v -> BanSyncResult.UNBAN_PLAYER); + } + } + }); } - - } diff --git a/common/src/main/java/com/discordsrv/common/bansync/enums/BanSyncResult.java b/common/src/main/java/com/discordsrv/common/bansync/enums/BanSyncResult.java index 245c2bae..a445ce19 100644 --- a/common/src/main/java/com/discordsrv/common/bansync/enums/BanSyncResult.java +++ b/common/src/main/java/com/discordsrv/common/bansync/enums/BanSyncResult.java @@ -3,18 +3,28 @@ package com.discordsrv.common.bansync.enums; public enum BanSyncResult { // Success, actioned - BAN_USER, - BAN_PLAYER, - UNBAN_USER, - UNBAN_PLAYER, + BAN_USER("Ban user"), + BAN_PLAYER("Ban player"), + UNBAN_USER("Unban user"), + UNBAN_PLAYER("Unban player"), // Nothing done - ALREADY_IN_SYNC, - WRONG_DIRECTION, + ALREADY_IN_SYNC("Already in sync"), + WRONG_DIRECTION("Wrong direction"), // Error - NO_PUNISHMENT_INTEGRATION, - NO_DISCORD_CONNECTION, - GUILD_DOESNT_EXIST + NO_PUNISHMENT_INTEGRATION("No punishment integration"), + NO_DISCORD_CONNECTION("No Discord connection"), + GUILD_DOESNT_EXIST("Guild doesn't exist"), + INVALID_CONFIG("Invalid config"); + private final String prettyResult; + + BanSyncResult(String prettyResult) { + this.prettyResult = prettyResult; + } + + public String prettyResult() { + return prettyResult; + } } diff --git a/common/src/main/java/com/discordsrv/common/command/combined/commands/ResyncCommand.java b/common/src/main/java/com/discordsrv/common/command/combined/commands/ResyncCommand.java index f14bc89b..f44c7407 100644 --- a/common/src/main/java/com/discordsrv/common/command/combined/commands/ResyncCommand.java +++ b/common/src/main/java/com/discordsrv/common/command/combined/commands/ResyncCommand.java @@ -8,12 +8,13 @@ import com.discordsrv.common.command.combined.abstraction.CommandExecution; import com.discordsrv.common.command.combined.abstraction.GameCommandExecution; import com.discordsrv.common.command.combined.abstraction.Text; import com.discordsrv.common.command.game.abstraction.GameCommand; +import com.discordsrv.common.config.main.GroupSyncConfig; import com.discordsrv.common.future.util.CompletableFutureUtil; import com.discordsrv.common.groupsync.GroupSyncModule; import com.discordsrv.common.groupsync.enums.GroupSyncCause; -import com.discordsrv.common.groupsync.enums.GroupSyncResult; import com.discordsrv.common.permission.Permission; import com.discordsrv.common.player.IPlayer; +import com.discordsrv.common.sync.ISyncResult; import net.kyori.adventure.text.format.NamedTextColor; import java.util.*; @@ -78,41 +79,45 @@ public class ResyncCommand extends CombinedCommand { execution.runAsync(() -> { long startTime = System.currentTimeMillis(); - CompletableFutureUtil.combine(resyncOnlinePlayers(module)) - .whenComplete((results, t) -> { - EnumMap resultCounts = new EnumMap<>(GroupSyncResult.class); - int total = 0; - for (List result : results) { - for (GroupSyncResult singleResult : result) { - total++; - resultCounts.computeIfAbsent(singleResult, key -> new AtomicInteger(0)).getAndIncrement(); - } - } - String resultHover = resultCounts.entrySet().stream() - .map(entry -> entry.getKey().toString() + ": " + entry.getValue().get()) - .collect(Collectors.joining("\n")); + CompletableFutureUtil.combine(resyncOnlinePlayers(module)).thenCompose(result -> { + List> results = new ArrayList<>(); + for (Map> map : result) { + results.addAll(map.values()); + } + return CompletableFutureUtil.combine(results); + }).whenComplete((results, t) -> { + Map resultCounts = new HashMap<>(); + int total = 0; - long time = System.currentTimeMillis() - startTime; - execution.send( - Arrays.asList( - new Text("Synchronization completed in ").withGameColor(NamedTextColor.GRAY), - new Text(time + "ms").withGameColor(NamedTextColor.GREEN).withFormatting(Text.Formatting.BOLD), - new Text(" (").withGameColor(NamedTextColor.GRAY), - new Text(total + " result" + (total == 1 ? "" : "s")) - .withGameColor(NamedTextColor.GREEN) - .withDiscordFormatting(Text.Formatting.BOLD), - new Text(")").withGameColor(NamedTextColor.GRAY) - ), - total > 0 - ? Collections.singletonList(new Text(resultHover)) - : (execution instanceof GameCommandExecution ? Collections.singletonList(new Text("Nothing done")) : Collections.emptyList()) - ); - }); + for (ISyncResult result : results) { + total++; + resultCounts.computeIfAbsent(result, key -> new AtomicInteger(0)).getAndIncrement(); + } + String resultHover = resultCounts.entrySet().stream() + .map(entry -> entry.getKey().toString() + ": " + entry.getValue().get()) + .collect(Collectors.joining("\n")); + + long time = System.currentTimeMillis() - startTime; + execution.send( + Arrays.asList( + new Text("Synchronization completed in ").withGameColor(NamedTextColor.GRAY), + new Text(time + "ms").withGameColor(NamedTextColor.GREEN).withFormatting(Text.Formatting.BOLD), + new Text(" (").withGameColor(NamedTextColor.GRAY), + new Text(total + " result" + (total == 1 ? "" : "s")) + .withGameColor(NamedTextColor.GREEN) + .withDiscordFormatting(Text.Formatting.BOLD), + new Text(")").withGameColor(NamedTextColor.GRAY) + ), + total > 0 + ? Collections.singletonList(new Text(resultHover)) + : (execution instanceof GameCommandExecution ? Collections.singletonList(new Text("Nothing done")) : Collections.emptyList()) + ); + }); }); } - private List>> resyncOnlinePlayers(GroupSyncModule module) { - List>> futures = new ArrayList<>(); + private List>>> resyncOnlinePlayers(GroupSyncModule module) { + List>>> futures = new ArrayList<>(); for (IPlayer player : discordSRV.playerProvider().allPlayers()) { futures.add(module.resync(player.uniqueId(), GroupSyncCause.COMMAND)); } diff --git a/common/src/main/java/com/discordsrv/common/config/main/BanSyncConfig.java b/common/src/main/java/com/discordsrv/common/config/main/BanSyncConfig.java new file mode 100644 index 00000000..f6276794 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/config/main/BanSyncConfig.java @@ -0,0 +1,55 @@ +package com.discordsrv.common.config.main; + +import com.discordsrv.common.config.main.generic.AbstractSyncConfig; +import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Comment; + +@ConfigSerializable +public class BanSyncConfig extends AbstractSyncConfig { + + @Comment("The id for the Discord server where the bans should be synced from/to") + public long serverId = 0L; + + @Comment("The reason applied when creating new bans in Minecraft") + public String gameBanReasonFormat = "%reason%"; + + @Comment("The punisher applied when creating new bans in Minecraft") + public String gamePunisherFormat = "@%user_effective_server_name%"; + + @Comment("The reason applied when creating new bans in Discord") + public String discordBanReasonFormat = "Banned by %punishment_punisher% in Minecraft for %punishment_reason%, ends: %punishment_until:'YYYY-MM-dd HH:mm:ss zzz'|text:'Never'%"; + + @Comment("The reason applied when removing bans in Discord") + public String discordUnbanReasonFormat = "Unbanned in Minecraft"; + + @Comment("The amount of hours to delete Discord messages, when syncing bans from Minecraft to Discord") + public int discordMessageHoursToDelete = 0; + + @Comment("Resync upon linking") + public boolean resyncUponLinking = true; + + @Override + public boolean isSet() { + return serverId != 0; + } + + @Override + public Void gameId() { + return null; + } + + @Override + public Long discordId() { + return serverId; + } + + @Override + public boolean isSameAs(BanSyncConfig config) { + return false; + } + + @Override + public String describe() { + return "Ban sync"; + } +} diff --git a/common/src/main/java/com/discordsrv/common/config/main/GroupSyncConfig.java b/common/src/main/java/com/discordsrv/common/config/main/GroupSyncConfig.java index 47573b1d..cb0ccf48 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/GroupSyncConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/GroupSyncConfig.java @@ -18,10 +18,9 @@ package com.discordsrv.common.config.main; -import com.discordsrv.common.DiscordSRV; import com.discordsrv.common.config.configurate.annotation.Constants; -import com.discordsrv.common.sync.enums.SyncDirection; -import com.discordsrv.common.sync.enums.SyncSide; +import com.discordsrv.common.config.main.generic.AbstractSyncConfig; +import org.apache.commons.lang3.StringUtils; import org.spongepowered.configurate.objectmapping.ConfigSerializable; import org.spongepowered.configurate.objectmapping.meta.Comment; @@ -30,11 +29,13 @@ import java.util.*; @ConfigSerializable public class GroupSyncConfig { - @Comment("Group-Role pairs for group synchronization") + @Comment("Group-Role pairs for group synchronization\n" + + "\n" + + "If you are not using LuckPerms and want to use Minecraft -> Discord synchronization, you must specify timed synchronization") public List pairs = new ArrayList<>(Collections.singletonList(new PairConfig())); @ConfigSerializable - public static class PairConfig { + public static class PairConfig extends AbstractSyncConfig { @Comment("The case-sensitive group name from your permissions plugin") public String groupName = ""; @@ -42,69 +43,43 @@ public class GroupSyncConfig { @Comment("The Discord role id") public Long roleId = 0L; - @Comment("The direction this group-role pair will synchronize in.\n" - + "Valid options: %1, %2, %3") - @Constants.Comment({"bidirectional", "minecraft_to_discord", "discord_to_minecraft"}) - public SyncDirection direction = SyncDirection.BIDIRECTIONAL; - - @Comment("Timed resynchronization.\n" - + "This is required if you're not using LuckPerms and want to use Minecraft to Discord synchronization") - public TimerConfig timer = new TimerConfig(); - - @ConfigSerializable - public static class TimerConfig { - - @Comment("If timed synchronization of this group-role pair is enabled") - public boolean enabled = true; - - @Comment("The amount of minutes between cycles") - public int cycleTime = 5; - } - - @Comment("Decides which side takes priority when using timed synchronization or the resync command\n" - + "Valid options: %1, %2") - @Constants.Comment({"minecraft", "discord"}) - public SyncSide tieBreaker = SyncSide.MINECRAFT; - @Comment("The LuckPerms \"%1\" context value, used when adding, removing and checking the groups of players.\n" + "Make this blank (\"\") to use the current server's value, or \"%2\" to not use the context") @Constants.Comment({"server", "global"}) public String serverContext = "global"; - public boolean isTheSameAs(PairConfig config) { - return groupName.equals(config.groupName) && Objects.equals(roleId, config.roleId); + public boolean isSet() { + return roleId != 0 && StringUtils.isNotEmpty(groupName); } - public boolean validate(DiscordSRV discordSRV) { - String label = "Group synchronization (" + groupName + ":" + Long.toUnsignedString(roleId) + ")"; - boolean invalidTieBreaker, invalidDirection = false; - if ((invalidTieBreaker = (tieBreaker == null)) || (invalidDirection = (direction == null))) { - if (invalidTieBreaker) { - discordSRV.logger().error(label + " has invalid tie-breaker: " + tieBreaker - + ", should be one of " + Arrays.toString(SyncSide.values())); - } - if (invalidDirection) { - discordSRV.logger().error(label + " has invalid direction: " + direction - + ", should be one of " + Arrays.toString(SyncDirection.values())); - } - return false; - } else if (direction != SyncDirection.BIDIRECTIONAL) { - boolean minecraft; - if ((direction == SyncDirection.MINECRAFT_TO_DISCORD) != (minecraft = (tieBreaker == SyncSide.MINECRAFT))) { - SyncSide opposite = (minecraft ? SyncSide.DISCORD : SyncSide.MINECRAFT); - discordSRV.logger().warning(label + " with direction " - + direction + " with tie-breaker " - + tieBreaker + " (should be " + opposite + ")"); - tieBreaker = opposite; // Fix the config - } - } - return true; + @Override + public String gameId() { + return makeGameId(groupName, serverContext != null ? Collections.singleton(serverContext) : null); + } + + @Override + public Long discordId() { + return roleId; + } + + @Override + public boolean isSameAs(PairConfig config) { + return groupName.equals(config.groupName) && Objects.equals(roleId, config.roleId); } @Override public String toString() { return "PairConfig{" + groupName + direction.arrow() + Long.toUnsignedString(roleId) + '}'; } + + @Override + public String describe() { + return "Group sync (" + groupName + ":" + Long.toUnsignedString(roleId) + ")"; + } + + public static String makeGameId(String groupName, Set serverContext) { + return groupName + (serverContext != null ? String.join(" ", serverContext) : ""); + } } } diff --git a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java index 7ec54e55..65c7c9b1 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/MainConfig.java @@ -81,6 +81,9 @@ public abstract class MainConfig implements Config { @Comment("Configuration options for group-role synchronization") public GroupSyncConfig groupSync = new GroupSyncConfig(); + @Comment("Configuration options for ban synchronization") + public BanSyncConfig banSync = new BanSyncConfig(); + @Comment("In-game command configuration") public GameCommandConfig gameCommand = new GameCommandConfig(); diff --git a/common/src/main/java/com/discordsrv/common/config/main/generic/AbstractSyncConfig.java b/common/src/main/java/com/discordsrv/common/config/main/generic/AbstractSyncConfig.java new file mode 100644 index 00000000..7e5c38df --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/config/main/generic/AbstractSyncConfig.java @@ -0,0 +1,72 @@ +package com.discordsrv.common.config.main.generic; + +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.config.configurate.annotation.Constants; +import com.discordsrv.common.sync.enums.SyncDirection; +import com.discordsrv.common.sync.enums.SyncSide; +import org.spongepowered.configurate.objectmapping.ConfigSerializable; +import org.spongepowered.configurate.objectmapping.meta.Comment; + +import java.util.Arrays; + +@ConfigSerializable +public abstract class AbstractSyncConfig, G, D> { + + @Comment("The direction to synchronize in.\n" + + "Valid options: %1, %2, %3") + @Constants.Comment({"bidirectional", "minecraft_to_discord", "discord_to_minecraft"}) + public SyncDirection direction = SyncDirection.BIDIRECTIONAL; + + @Comment("Timed resynchronization") + public TimerConfig timer = new TimerConfig(); + + @ConfigSerializable + public static class TimerConfig { + + @Comment("If timed synchronization is enabled") + public boolean enabled = true; + + @Comment("The amount of minutes between timed synchronization cycles") + public int cycleTime = 5; + } + + @Comment("Decides which side takes priority when using timed synchronization or the resync command and there are differences\n" + + "Valid options: %1, %2") + @Constants.Comment({"minecraft", "discord"}) + public SyncSide tieBreaker = SyncSide.MINECRAFT; + + public abstract boolean isSet(); + + public abstract G gameId(); + public abstract D discordId(); + public abstract boolean isSameAs(C otherConfig); + + public abstract String describe(); + + public boolean validate(DiscordSRV discordSRV) { + String label = describe(); + boolean invalidTieBreaker, invalidDirection = false; + if ((invalidTieBreaker = (tieBreaker == null)) || (invalidDirection = (direction == null))) { + if (invalidTieBreaker) { + discordSRV.logger().error(label + " has invalid tie-breaker: " + tieBreaker + + ", should be one of " + Arrays.toString(SyncSide.values())); + } + if (invalidDirection) { + discordSRV.logger().error(label + " has invalid direction: " + direction + + ", should be one of " + Arrays.toString(SyncDirection.values())); + } + return false; + } else if (direction != SyncDirection.BIDIRECTIONAL) { + boolean minecraft; + if ((direction == SyncDirection.MINECRAFT_TO_DISCORD) != (minecraft = (tieBreaker == SyncSide.MINECRAFT))) { + SyncSide opposite = (minecraft ? SyncSide.DISCORD : SyncSide.MINECRAFT); + discordSRV.logger().warning( + label + " with direction " + direction + " with tie-breaker " + tieBreaker + " (should be " + opposite + ")" + ); + tieBreaker = opposite; // Fix the config + } + } + return true; + } + +} diff --git a/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java b/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java index 4743f7a2..a3328268 100644 --- a/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java +++ b/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java @@ -389,6 +389,9 @@ public class JDAConnectionManager implements DiscordConnectionManager { // We don't use MDC jdaBuilder.setContextEnabled(false); + // Enable event passthrough + jdaBuilder.setEventPassthrough(true); + // Custom event manager to forward to the DiscordSRV event bus & block using JDA's event listeners jdaBuilder.setEventManager(new EventManagerProxy(new JDAEventManager(discordSRV), discordSRV.scheduler().forkJoinPool())); diff --git a/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncModule.java b/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncModule.java index 36e6cdd5..5a73f54d 100644 --- a/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncModule.java +++ b/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncModule.java @@ -1,24 +1,5 @@ -/* - * This file is part of DiscordSRV, licensed under the GPLv3 License - * Copyright (c) 2016-2023 Austin "Scarsz" Shapiro, Henri "Vankka" Schubin and DiscordSRV contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - package com.discordsrv.common.groupsync; -import com.discordsrv.api.DiscordSRVApi; import com.discordsrv.api.discord.entity.guild.DiscordRole; import com.discordsrv.api.event.bus.Subscribe; import com.discordsrv.api.event.events.discord.member.role.DiscordMemberRoleAddEvent; @@ -31,50 +12,52 @@ import com.discordsrv.common.debug.file.TextDebugFile; import com.discordsrv.common.event.events.player.PlayerConnectedEvent; import com.discordsrv.common.future.util.CompletableFutureUtil; import com.discordsrv.common.groupsync.enums.GroupSyncCause; -import com.discordsrv.common.sync.enums.SyncDirection; import com.discordsrv.common.groupsync.enums.GroupSyncResult; -import com.discordsrv.common.sync.enums.SyncSide; -import com.discordsrv.common.logging.NamedLogger; -import com.discordsrv.common.module.type.AbstractModule; import com.discordsrv.common.player.IPlayer; -import com.discordsrv.common.profile.Profile; +import com.discordsrv.common.sync.AbstractSyncModule; +import com.discordsrv.common.sync.ISyncResult; +import com.discordsrv.common.sync.SyncFail; +import com.discordsrv.common.sync.enums.SyncResults; import com.github.benmanes.caffeine.cache.Cache; -import org.apache.commons.lang3.StringUtils; import org.jetbrains.annotations.Nullable; -import java.time.Duration; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; -public class GroupSyncModule extends AbstractModule { - - private final Map> pairs = new LinkedHashMap<>(); - private final Map> groupsToPairs = new ConcurrentHashMap<>(); - private final Map> rolesToPairs = new ConcurrentHashMap<>(); +public class GroupSyncModule extends AbstractSyncModule { private final Cache> expectedDiscordChanges; private final Cache> expectedMinecraftChanges; public GroupSyncModule(DiscordSRV discordSRV) { - super(discordSRV, new NamedLogger(discordSRV, "GROUP_SYNC")); + super(discordSRV, "GROUP_SYNC"); + this.expectedDiscordChanges = discordSRV.caffeineBuilder() .expireAfterWrite(30, TimeUnit.SECONDS) .build(); - this.expectedMinecraftChanges = discordSRV.caffeineBuilder() .expireAfterWrite(30, TimeUnit.SECONDS) .build(); } + @Override + public List configs() { + return discordSRV.config().groupSync.pairs; + } + + @Override + protected boolean isTrue(Boolean state) { + return state; + } + @Override public boolean isEnabled() { boolean any = false; for (GroupSyncConfig.PairConfig pair : discordSRV.config().groupSync.pairs) { - if (pair.roleId != 0 && StringUtils.isNotEmpty(pair.groupName)) { + if (pair.isSet()) { any = true; break; } @@ -86,67 +69,11 @@ public class GroupSyncModule extends AbstractModule { return super.isEnabled(); } - @Override - public void reload(Consumer resultConsumer) { - synchronized (pairs) { - pairs.values().forEach(future -> { - if (future != null) { - future.cancel(false); - } - }); - pairs.clear(); - groupsToPairs.clear(); - rolesToPairs.clear(); - - GroupSyncConfig config = discordSRV.config().groupSync; - for (GroupSyncConfig.PairConfig pair : config.pairs) { - String groupName = pair.groupName; - long roleId = pair.roleId; - if (StringUtils.isEmpty(groupName) || roleId == 0) { - continue; - } - - if (!pair.validate(discordSRV)) { - continue; - } - - boolean failed = false; - for (GroupSyncConfig.PairConfig pairConfig : config.pairs) { - if (pairConfig != pair && pair.isTheSameAs(pairConfig)) { - failed = true; - break; - } - } - if (failed) { - discordSRV.logger().error("Duplicate group synchronization pair: " + groupName + " to " + roleId); - continue; - } - - Future future = null; - GroupSyncConfig.PairConfig.TimerConfig timer = pair.timer; - if (timer != null && timer.enabled) { - int cycleTime = timer.cycleTime; - future = discordSRV.scheduler().runAtFixedRate( - () -> resyncPair(pair, GroupSyncCause.TIMER), - Duration.ofMinutes(cycleTime), - Duration.ofMinutes(cycleTime) - ); - } - - pairs.put(pair, future); - groupsToPairs.computeIfAbsent(groupName, key -> new ArrayList<>()).add(pair); - rolesToPairs.computeIfAbsent(roleId, key -> new ArrayList<>()).add(pair); - } - } - } - - // Debug - @Subscribe public void onDebugGenerate(DebugGenerateEvent event) { StringBuilder builder = new StringBuilder("Active pairs:"); - for (Map.Entry> entry : pairs.entrySet()) { + for (Map.Entry> entry : syncs.entrySet()) { GroupSyncConfig.PairConfig pair = entry.getKey(); builder.append("\n- ").append(pair) .append(" (tie-breaker: ").append(pair.tieBreaker) @@ -171,13 +98,29 @@ public class GroupSyncModule extends AbstractModule { } private void logSummary( - UUID player, + UUID playerUUID, GroupSyncCause cause, - Map> pairs + CompletableFuture>> future + ) { + future.whenComplete((result, t) -> { + if (t != null) { + // TODO: "not linked" doesn't need to be a error/ other errors that don't need to be errors? + logger().error("Failed to sync groups (" + cause + ") for " + playerUUID, t); + return; + } + + logSummary(playerUUID, cause, result); + }); + } + + private void logSummary( + UUID playerUUID, + GroupSyncCause cause, + Map> pairs ) { CompletableFutureUtil.combine(pairs.values()).whenComplete((v, t) -> { - GroupSyncSummary summary = new GroupSyncSummary(player, cause); - for (Map.Entry> entry : pairs.entrySet()) { + GroupSyncSummary summary = new GroupSyncSummary(playerUUID, cause); + for (Map.Entry> entry : pairs.entrySet()) { summary.add(entry.getKey(), entry.getValue().join()); } @@ -191,507 +134,245 @@ public class GroupSyncModule extends AbstractModule { }); } - // Linked account helper methods - - private CompletableFuture lookupLinkedAccount(UUID player) { - return discordSRV.profileManager().lookupProfile(player) - .thenApply(Profile::userId); - } - - private CompletableFuture lookupLinkedAccount(long userId) { - return discordSRV.profileManager().lookupProfile(userId) - .thenApply(Profile::playerUUID); - } - - // Permission data helper methods - - private PermissionModule.Groups getPermissionProvider() { - PermissionModule.GroupsContext groupsContext = discordSRV.getModule(PermissionModule.GroupsContext.class); - return groupsContext == null ? discordSRV.getModule(PermissionModule.Groups.class) : groupsContext; - } - - public boolean noPermissionProvider() { - PermissionModule.Groups groups = getPermissionProvider(); - return groups == null || !groups.isEnabled(); - } - - private boolean supportsOffline() { - return getPermissionProvider().supportsOffline(); - } - - private CompletableFuture hasGroup( - UUID player, - String groupName, - @Nullable String serverContext - ) { - PermissionModule.Groups permissionProvider = getPermissionProvider(); - if (permissionProvider instanceof PermissionModule.GroupsContext) { - return ((PermissionModule.GroupsContext) permissionProvider) - .hasGroup(player, groupName, false, serverContext != null ? Collections.singleton(serverContext) : null); - } else { - return permissionProvider.hasGroup(player, groupName, false); - } - } - - private CompletableFuture addGroup( - UUID player, - String groupName, - @Nullable String serverContext - ) { - PermissionModule.Groups permissionProvider = getPermissionProvider(); - if (permissionProvider instanceof PermissionModule.GroupsContext) { - return ((PermissionModule.GroupsContext) permissionProvider) - .addGroup(player, groupName, Collections.singleton(serverContext)); - } else { - return permissionProvider.addGroup(player, groupName); - } - } - - private CompletableFuture removeGroup( - UUID player, - String groupName, - @Nullable String serverContext - ) { - PermissionModule.Groups permissionProvider = getPermissionProvider(); - if (permissionProvider instanceof PermissionModule.GroupsContext) { - return ((PermissionModule.GroupsContext) permissionProvider) - .removeGroup(player, groupName, Collections.singleton(serverContext)); - } else { - return permissionProvider.removeGroup(player, groupName); - } - } - - // Resync user - - public CompletableFuture> resync(UUID player, GroupSyncCause cause) { - return lookupLinkedAccount(player).thenCompose(userId -> { - if (userId == null) { - return CompletableFuture.completedFuture(Collections.emptyList()); - } - - return CompletableFutureUtil.combine(resync(player, userId, cause)); - }); - } - - public CompletableFuture> resync(long userId, GroupSyncCause cause) { - return lookupLinkedAccount(userId).thenCompose(player -> { - if (player == null) { - return CompletableFuture.completedFuture(Collections.emptyList()); - } - - return CompletableFutureUtil.combine(resync(player, userId, cause)); - }); - } - - public Collection> resync(UUID player, long userId, GroupSyncCause cause) { - if (noPermissionProvider()) { - return Collections.singletonList(CompletableFuture.completedFuture(GroupSyncResult.NO_PERMISSION_PROVIDER)); - } else if (discordSRV.playerProvider().player(player) == null && !supportsOffline()) { - return Collections.singletonList(CompletableFuture.completedFuture(GroupSyncResult.PERMISSION_PROVIDER_NO_OFFLINE_SUPPORT)); - } - - Map> futures = new LinkedHashMap<>(); - for (GroupSyncConfig.PairConfig pair : pairs.keySet()) { - futures.put(pair, resyncPair(pair, player, userId)); - } - - logSummary(player, cause, futures); - return futures.values(); - } - - private void resyncPair(GroupSyncConfig.PairConfig pair, GroupSyncCause cause) { - if (noPermissionProvider()) { - return; - } - - for (IPlayer player : discordSRV.playerProvider().allPlayers()) { - UUID uuid = player.uniqueId(); - lookupLinkedAccount(uuid).whenComplete((userId, t) -> { - if (userId == null) { - return; - } - - resyncPair(pair, uuid, userId).whenComplete((result, t2) -> logger().debug( - new GroupSyncSummary(uuid, cause, pair, result).toString() - )); - }); - } - } - - private CompletableFuture resyncPair(GroupSyncConfig.PairConfig pair, UUID player, long userId) { - DiscordRole role = discordSRV.discordAPI().getRoleById(pair.roleId); - if (role == null) { - return CompletableFuture.completedFuture(GroupSyncResult.ROLE_DOESNT_EXIST); - } - - if (!role.getGuild().getSelfMember().canInteract(role)) { - return CompletableFuture.completedFuture(GroupSyncResult.ROLE_CANNOT_INTERACT); - } - - return role.getGuild().retrieveMemberById(userId).thenCompose(member -> { - if (member == null) { - return CompletableFuture.completedFuture(GroupSyncResult.NOT_A_GUILD_MEMBER); - } - - boolean hasRole = member.hasRole(role); - String groupName = pair.groupName; - CompletableFuture resultFuture = new CompletableFuture<>(); - - hasGroup(player, groupName, pair.serverContext).whenComplete((hasGroup, t) -> { - if (t != null) { - discordSRV.logger().error("Failed to check if player " + player + " has group " + groupName, t); - resultFuture.complete(GroupSyncResult.PERMISSION_BACKEND_FAIL_CHECK); - return; - } - - if (hasRole == hasGroup) { - resultFuture.complete(hasRole ? GroupSyncResult.BOTH_TRUE : GroupSyncResult.BOTH_FALSE); - // We're all good - return; - } - - SyncSide side = pair.tieBreaker; - SyncDirection direction = pair.direction; - CompletableFuture future; - GroupSyncResult result; - if (hasRole) { - if (side == SyncSide.DISCORD) { - // Has role, add group - if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { - resultFuture.complete(GroupSyncResult.WRONG_DIRECTION); - return; - } - - result = GroupSyncResult.ADD_GROUP; - future = addGroup(player, groupName, pair.serverContext); - } else { - // Doesn't have group, remove role - if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { - resultFuture.complete(GroupSyncResult.WRONG_DIRECTION); - return; - } - - result = GroupSyncResult.REMOVE_ROLE; - future = member.removeRole(role); - } - } else { - if (side == SyncSide.DISCORD) { - // Doesn't have role, remove group - if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { - resultFuture.complete(GroupSyncResult.WRONG_DIRECTION); - return; - } - - result = GroupSyncResult.REMOVE_GROUP; - future = removeGroup(player, groupName, pair.serverContext); - } else { - // Has group, add role - if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { - resultFuture.complete(GroupSyncResult.WRONG_DIRECTION); - return; - } - - result = GroupSyncResult.ADD_ROLE; - future = member.addRole(role); - } - } - future.whenComplete((v, t2) -> { - if (t2 != null) { - discordSRV.logger().error("Failed to " + result + " to " + player + "/" + Long.toUnsignedString(userId), t2); - resultFuture.complete(GroupSyncResult.UPDATE_FAILED); - return; - } - - resultFuture.complete(result); - }); - }); - - return resultFuture; - }); - } - // Listeners & methods to indicate something changed @Subscribe public void onPlayerConnected(PlayerConnectedEvent event) { - resync(event.player().uniqueId(), GroupSyncCause.GAME_JOIN); + UUID playerUUID = event.player().uniqueId(); + logSummary(playerUUID, GroupSyncCause.GAME_JOIN, resyncAll(playerUUID)); } @Subscribe public void onDiscordMemberRoleAdd(DiscordMemberRoleAddEvent event) { - event.getRoles().forEach(role -> roleChanged(event.getMember().getUser().getId(), role.getId(), false)); + event.getRoles().forEach(role -> roleChanged(event.getMember().getUser().getId(), role.getId(), true)); } @Subscribe public void onDiscordMemberRoleRemove(DiscordMemberRoleRemoveEvent event) { - event.getRoles().forEach(role -> roleChanged(event.getMember().getUser().getId(), role.getId(), true)); + event.getRoles().forEach(role -> roleChanged(event.getMember().getUser().getId(), role.getId(), false)); } public void groupAdded(UUID player, String groupName, @Nullable Set serverContext, GroupSyncCause cause) { - groupChanged(player, groupName, serverContext, cause, false); - } - - public void groupRemoved(UUID player, String groupName, @Nullable Set serverContext, GroupSyncCause cause) { groupChanged(player, groupName, serverContext, cause, true); } - // Internal handling of changes + public void groupRemoved(UUID player, String groupName, @Nullable Set serverContext, GroupSyncCause cause) { + groupChanged(player, groupName, serverContext, cause, false); + } - private boolean checkExpectation(Cache> expectations, T key, R mapKey, boolean remove) { + private void roleChanged(long userId, long roleId, boolean state) { + if (checkExpectation(expectedDiscordChanges, userId, roleId, state)) { + return; + } + + PermissionModule.Groups permissionProvider = getPermissionProvider(); + if (permissionProvider == null) { + logger().debug("No permission provider"); + return; + } + + lookupLinkedAccount(userId).whenComplete((playerUUID, t) -> { + if (playerUUID == null) { + return; + } + + if (!permissionProvider.supportsOffline() && discordSRV.playerProvider().player(playerUUID) == null) { + logger().debug("Not running sync for " + playerUUID + ": permission provider does not support offline operations"); + return; + } + + logSummary(playerUUID, GroupSyncCause.DISCORD_ROLE_CHANGE, discordChanged(userId, playerUUID, roleId, state)); + }); + } + + private void groupChanged( + UUID playerUUID, + String groupName, + Set serverContext, + GroupSyncCause cause, + Boolean state + ) { + if (cause.isDiscordSRVCanCause() && checkExpectation(expectedMinecraftChanges, playerUUID, groupName, state)) { + return; + } + + PermissionModule.Groups permissionProvider = getPermissionProvider(); + if (permissionProvider == null) { + logger().debug("No permission provider"); + return; + } + + if (!permissionProvider.supportsOffline() && discordSRV.playerProvider().player(playerUUID) == null) { + return; + } + + lookupLinkedAccount(playerUUID).whenComplete((userId, t) -> { + if (userId == null) { + return; + } + + logSummary(playerUUID, cause, gameChanged(userId, playerUUID, context(groupName, serverContext), state)); + }); + } + + private PermissionModule.Groups getPermissionProvider() { + PermissionModule.GroupsContext groupsContext = discordSRV.getModule(PermissionModule.GroupsContext.class); + return groupsContext != null ? groupsContext : discordSRV.getModule(PermissionModule.Groups.class); + } + + public boolean noPermissionProvider() { + return getPermissionProvider() != null; + } + + private boolean checkExpectation(Cache> expectations, T key, R mapKey, boolean newState) { // Check if we were expecting the change (when we add/remove something due to synchronization), // if we did expect the change, we won't trigger a synchronization since we just synchronized what was needed Map expected = expectations.getIfPresent(key); - if (expected != null && Objects.equals(expected.get(mapKey), remove)) { + if (expected != null && Objects.equals(expected.get(mapKey), newState)) { expected.remove(mapKey); return true; } return false; } - private void roleChanged(long userId, long roleId, boolean remove) { - if (noPermissionProvider()) { - return; + // Resync + + @Override + protected void resyncTimer(GroupSyncConfig.PairConfig config) { + for (IPlayer player : discordSRV.playerProvider().allPlayers()) { + UUID playerUUID = player.uniqueId(); + lookupLinkedAccount(playerUUID) + .whenComplete((userId, t) -> { + if (userId == null) { + return; + } + + logSummary(playerUUID, GroupSyncCause.TIMER, resync(config, playerUUID, userId).thenApply(result -> { + Map> map = new HashMap<>(); + map.put(config, CompletableFuture.completedFuture(result)); + return map; + })); + }); } - - if (checkExpectation(expectedDiscordChanges, userId, roleId, remove)) { - return; - } - - lookupLinkedAccount(userId).whenComplete((player, t) -> { - if (player == null) { - return; - } - - roleChanged(userId, player, roleId, remove); - }); } - private void roleChanged(long userId, UUID player, long roleId, boolean remove) { - List pairs = rolesToPairs.get(roleId); - if (pairs == null) { - return; - } - - PermissionModule.Groups permissionProvider = getPermissionProvider(); - if (permissionProvider == null) { - discordSRV.logger().warning("No supported permission plugin available to perform group sync"); - return; - } - - Map> futures = new LinkedHashMap<>(); - for (GroupSyncConfig.PairConfig pair : pairs) { - SyncDirection direction = pair.direction; - if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { - // Not going Discord -> Minecraft - futures.put(pair, CompletableFuture.completedFuture(GroupSyncResult.WRONG_DIRECTION)); - continue; - } - - futures.put(pair, modifyGroupState(player, pair, remove)); - - // If the sync is bidirectional, also add/remove any other roles that are linked to this group - if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { - continue; - } - - List groupPairs = groupsToPairs.get(pair.groupName); - if (groupPairs == null) { - continue; - } - - for (GroupSyncConfig.PairConfig groupPair : groupPairs) { - if (groupPair.roleId == roleId) { - continue; - } - - futures.put(groupPair, modifyRoleState(userId, groupPair, remove)); - } - } - logSummary(player, GroupSyncCause.DISCORD_ROLE_CHANGE, futures); - } - - private void groupChanged( - UUID player, - String groupName, - @Nullable Set serverContext, - GroupSyncCause cause, - boolean remove - ) { - if (noPermissionProvider()) { - return; - } - - if (cause.isDiscordSRVCanCause() && checkExpectation(expectedMinecraftChanges, player, groupName, remove)) { - return; - } - - lookupLinkedAccount(player).whenComplete((userId, t) -> { - if (userId == null) { - return; - } - - groupChanged(player, userId, groupName, serverContext, cause, remove); - }); - } - - private void groupChanged( - UUID player, - long userId, - String groupName, - @Nullable Set serverContext, - GroupSyncCause cause, - boolean remove - ) { - List pairs = groupsToPairs.get(groupName); - if (pairs == null) { - return; - } - - PermissionModule.Groups permissionProvider = getPermissionProvider(); - Map> futures = new LinkedHashMap<>(); - for (GroupSyncConfig.PairConfig pair : pairs) { - SyncDirection direction = pair.direction; - if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { - // Not going Minecraft -> Discord - futures.put(pair, CompletableFuture.completedFuture(GroupSyncResult.WRONG_DIRECTION)); - continue; - } - - // Check if we're in the right context - String context = pair.serverContext; - if (permissionProvider instanceof PermissionModule.GroupsContext) { - if (StringUtils.isEmpty(context)) { - // Use the default server context of the server - Set defaultValues = ((PermissionModule.GroupsContext) permissionProvider) - .getDefaultServerContext(); - if (!Objects.equals(serverContext, defaultValues)) { - continue; - } - } else if (context.equals("global")) { - // No server context - if (serverContext != null && !serverContext.isEmpty()) { - continue; - } - } else { - // Server context has to match the specified - if (serverContext == null - || serverContext.size() != 1 - || !serverContext.iterator().next().equals(context)) { - continue; - } - } - } - - futures.put(pair, modifyRoleState(userId, pair, remove)); - - // If the sync is bidirectional, also add/remove any other groups that are linked to this role - if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { - continue; - } - - long roleId = pair.roleId; - List rolePairs = rolesToPairs.get(roleId); - if (rolePairs == null || rolePairs.isEmpty()) { - continue; - } - - for (GroupSyncConfig.PairConfig rolePair : rolePairs) { - if (rolePair.groupName.equals(groupName)) { - continue; - } - - futures.put(rolePair, modifyGroupState(player, rolePair, remove)); - } - } - logSummary(player, cause, futures); - } - - private CompletableFuture modifyGroupState(UUID player, GroupSyncConfig.PairConfig config, boolean remove) { - String groupName = config.groupName; - - Map expected = expectedMinecraftChanges.get(player, key -> new ConcurrentHashMap<>()); - if (expected != null) { - expected.put(groupName, remove); - } - - CompletableFuture future = new CompletableFuture<>(); - String serverContext = config.serverContext; - hasGroup(player, groupName, serverContext).thenCompose(hasGroup -> { - if (remove && hasGroup) { - return removeGroup(player, groupName, serverContext).thenApply(v -> GroupSyncResult.REMOVE_GROUP); - } else if (!remove && !hasGroup) { - return addGroup(player, groupName, serverContext).thenApply(v -> GroupSyncResult.ADD_GROUP); - } else { - // Nothing to do - return CompletableFuture.completedFuture(GroupSyncResult.ALREADY_IN_SYNC); - } - }).whenComplete((result, t) -> { - if (t != null) { - if (expected != null) { - // Failed, remove expectation - expected.remove(groupName); - } - - future.complete(GroupSyncResult.UPDATE_FAILED); - discordSRV.logger().error("Failed to add group " + groupName + " to " + player, t); - return; - } - - future.complete(result); - }); + public CompletableFuture>> resync(UUID playerUUID, GroupSyncCause cause) { + CompletableFuture>> future = resyncAll(playerUUID); + logSummary(playerUUID, cause, future); return future; } - private CompletableFuture modifyRoleState(long userId, GroupSyncConfig.PairConfig config, boolean remove) { - long roleId = config.roleId; - DiscordRole role = discordSRV.discordAPI().getRoleById(roleId); + @Override + public CompletableFuture getDiscord(GroupSyncConfig.PairConfig config, long userId) { + DiscordRole role = discordSRV.discordAPI().getRoleById(config.roleId); if (role == null) { - return CompletableFuture.completedFuture(GroupSyncResult.ROLE_DOESNT_EXIST); + return CompletableFutureUtil.failed(new SyncFail(GroupSyncResult.ROLE_DOESNT_EXIST)); } if (!role.getGuild().getSelfMember().canInteract(role)) { - return CompletableFuture.completedFuture(GroupSyncResult.ROLE_CANNOT_INTERACT); + return CompletableFutureUtil.failed(new SyncFail(GroupSyncResult.ROLE_CANNOT_INTERACT)); } - return role.getGuild().retrieveMemberById(userId).thenCompose(member -> { + return role.getGuild().retrieveMemberById(userId).thenApply(member -> { if (member == null) { - return CompletableFuture.completedFuture(GroupSyncResult.NOT_A_GUILD_MEMBER); + throw new SyncFail(GroupSyncResult.NOT_A_GUILD_MEMBER); } - Map expected = expectedDiscordChanges.get(userId, key -> new ConcurrentHashMap<>()); - if (expected != null) { - expected.put(roleId, remove); - } - - boolean hasRole = member.hasRole(role); - CompletableFuture future; - if (remove && hasRole) { - future = member.removeRole(role).thenApply(v -> GroupSyncResult.REMOVE_ROLE); - } else if (!remove && !hasRole) { - future = member.addRole(role).thenApply(v -> GroupSyncResult.ADD_ROLE); - } else { - if (expected != null) { - // Nothing needed to be changed, remove expectation - expected.remove(roleId); - } - return CompletableFuture.completedFuture(GroupSyncResult.ALREADY_IN_SYNC); - } - - CompletableFuture resultFuture = new CompletableFuture<>(); - future.whenComplete((result, t) -> { - if (t != null) { - if (expected != null) { - // Failed, remove expectation - expected.remove(roleId); - } - - resultFuture.complete(GroupSyncResult.UPDATE_FAILED); - discordSRV.logger().error("Failed to give/take role " + role + " to/from " + member, t); - return; - } - resultFuture.complete(result); - }); - return resultFuture; + return member.hasRole(role); }); } + + @Override + public CompletableFuture getGame(GroupSyncConfig.PairConfig config, UUID playerUUID) { + PermissionModule.Groups permissionProvider = getPermissionProvider(); + CompletableFuture future; + if (permissionProvider instanceof PermissionModule.GroupsContext) { + future = ((PermissionModule.GroupsContext) permissionProvider) + .hasGroup(playerUUID, config.groupName, false, config.serverContext != null ? Collections.singleton(config.serverContext) : null); + } else { + future = permissionProvider.hasGroup(playerUUID, config.groupName, false); + } + + return future.exceptionally(t -> { + throw new SyncFail(GroupSyncResult.PERMISSION_BACKEND_FAILED, t); + }); + } + + @Override + public CompletableFuture applyDiscord(GroupSyncConfig.PairConfig config, long userId, Boolean state) { + DiscordRole role = discordSRV.discordAPI().getRoleById(config.roleId); + if (role == null) { + return CompletableFutureUtil.failed(new SyncFail(GroupSyncResult.ROLE_DOESNT_EXIST)); + } + + Map expected = expectedDiscordChanges.get(userId, key -> new ConcurrentHashMap<>()); + if (expected != null) { + expected.put(config.roleId, state); + } + + return role.getGuild().retrieveMemberById(userId) + .thenCompose(member -> state + ? member.addRole(role).thenApply(v -> (ISyncResult) SyncResults.ADD_DISCORD) + : member.removeRole(role).thenApply(v -> SyncResults.REMOVE_DISCORD) + ).whenComplete((r, t) -> { + if (t != null) { + //noinspection DataFlowIssue + expected.remove(config.roleId); + } + }); + } + + @Override + public CompletableFuture applyGame(GroupSyncConfig.PairConfig config, UUID playerUUID, Boolean state) { + Map expected = expectedMinecraftChanges.get(playerUUID, key -> new ConcurrentHashMap<>()); + if (expected != null) { + expected.put(config.groupName, state); + } + + CompletableFuture future = + state + ? addGroup(playerUUID, config).thenApply(v -> SyncResults.ADD_GAME) + : removeGroup(playerUUID, config).thenApply(v -> SyncResults.REMOVE_GAME); + return future.exceptionally(t -> { + //noinspection DataFlowIssue + expected.remove(config.groupName); + throw new SyncFail(GroupSyncResult.PERMISSION_BACKEND_FAILED, t); + }); + } + + private Set context(GroupSyncConfig.PairConfig config) { + return config.serverContext != null ? Collections.singleton(config.serverContext) : null; + } + + private String context(String groupName, Set serverContext) { + if (serverContext == null || serverContext.isEmpty()) { + return GroupSyncConfig.PairConfig.makeGameId(groupName, Collections.singleton("global")); + } + if (serverContext.size() == 1 && serverContext.iterator().next().isEmpty()) { + return null; + } + return GroupSyncConfig.PairConfig.makeGameId(groupName, serverContext); + } + + private CompletableFuture addGroup(UUID player, GroupSyncConfig.PairConfig config) { + PermissionModule.Groups permissionProvider = getPermissionProvider(); + String groupName = config.groupName; + if (permissionProvider instanceof PermissionModule.GroupsContext) { + return ((PermissionModule.GroupsContext) permissionProvider) + .addGroup(player, groupName, context(config)); + } else { + return permissionProvider.addGroup(player, groupName); + } + } + + private CompletableFuture removeGroup(UUID player, GroupSyncConfig.PairConfig config) { + PermissionModule.Groups permissionProvider = getPermissionProvider(); + String groupName = config.groupName; + if (permissionProvider instanceof PermissionModule.GroupsContext) { + return ((PermissionModule.GroupsContext) permissionProvider) + .removeGroup(player, groupName, context(config)); + } else { + return permissionProvider.removeGroup(player, groupName); + } + } } diff --git a/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncSummary.java b/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncSummary.java index 62251ec2..f67b8e17 100644 --- a/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncSummary.java +++ b/common/src/main/java/com/discordsrv/common/groupsync/GroupSyncSummary.java @@ -20,17 +20,17 @@ package com.discordsrv.common.groupsync; import com.discordsrv.common.config.main.GroupSyncConfig; import com.discordsrv.common.groupsync.enums.GroupSyncCause; -import com.discordsrv.common.groupsync.enums.GroupSyncResult; +import com.discordsrv.common.sync.ISyncResult; import java.util.*; public class GroupSyncSummary { - private final EnumMap> pairs = new EnumMap<>(GroupSyncResult.class); + private final Map> pairs = new HashMap<>(); private final UUID player; private final GroupSyncCause cause; - public GroupSyncSummary(UUID player, GroupSyncCause cause, GroupSyncConfig.PairConfig config, GroupSyncResult result) { + public GroupSyncSummary(UUID player, GroupSyncCause cause, GroupSyncConfig.PairConfig config, ISyncResult result) { this(player, cause); add(config, result); } @@ -40,12 +40,12 @@ public class GroupSyncSummary { this.cause = cause; } - public void add(GroupSyncConfig.PairConfig config, GroupSyncResult result) { + public void add(GroupSyncConfig.PairConfig config, ISyncResult result) { pairs.computeIfAbsent(result, key -> new LinkedHashSet<>()).add(config); } public boolean anySuccess() { - for (GroupSyncResult result : pairs.keySet()) { + for (ISyncResult result : pairs.keySet()) { if (result.isSuccess()) { return true; } @@ -59,7 +59,7 @@ public class GroupSyncSummary { StringBuilder message = new StringBuilder( "Group synchronization (of " + count + " pair" + (count == 1 ? "" : "s") + ") for " + player + " (" + cause + ")"); - for (Map.Entry> entry : pairs.entrySet()) { + for (Map.Entry> entry : pairs.entrySet()) { message.append(count == 1 ? ": " : "\n") .append(entry.getKey().toString()) .append(": ") diff --git a/common/src/main/java/com/discordsrv/common/groupsync/enums/GroupSyncResult.java b/common/src/main/java/com/discordsrv/common/groupsync/enums/GroupSyncResult.java index 72aeeff4..eda98ff2 100644 --- a/common/src/main/java/com/discordsrv/common/groupsync/enums/GroupSyncResult.java +++ b/common/src/main/java/com/discordsrv/common/groupsync/enums/GroupSyncResult.java @@ -18,25 +18,15 @@ package com.discordsrv.common.groupsync.enums; -public enum GroupSyncResult { +import com.discordsrv.common.sync.ISyncResult; - // Something happened - ADD_GROUP("Success (group add)", true), - REMOVE_GROUP("Success (group remove)", true), - ADD_ROLE("Success (role add)", true), - REMOVE_ROLE("Success (role remove)", true), - - // Nothing done - ALREADY_IN_SYNC("Already in sync"), - WRONG_DIRECTION("Wrong direction"), - BOTH_TRUE("Both sides true"), - BOTH_FALSE("Both sides false"), +public enum GroupSyncResult implements ISyncResult { // Errors ROLE_DOESNT_EXIST("Role doesn't exist"), ROLE_CANNOT_INTERACT("Bot doesn't have a role above the synced role (cannot interact)"), NOT_A_GUILD_MEMBER("User is not part of the server the role is in"), - PERMISSION_BACKEND_FAIL_CHECK("Failed to check group status, error printed"), + PERMISSION_BACKEND_FAILED("Failed to check group status, error printed"), UPDATE_FAILED("Failed to modify role/group, error printed"), NO_PERMISSION_PROVIDER("No permission provider"), PERMISSION_PROVIDER_NO_OFFLINE_SUPPORT("Permission provider doesn't support offline players"), diff --git a/common/src/main/java/com/discordsrv/common/sync/AbstractSyncModule.java b/common/src/main/java/com/discordsrv/common/sync/AbstractSyncModule.java new file mode 100644 index 00000000..d147108e --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/sync/AbstractSyncModule.java @@ -0,0 +1,296 @@ +package com.discordsrv.common.sync; + +import com.discordsrv.api.DiscordSRVApi; +import com.discordsrv.common.DiscordSRV; +import com.discordsrv.common.config.main.GroupSyncConfig; +import com.discordsrv.common.config.main.generic.AbstractSyncConfig; +import com.discordsrv.common.future.util.CompletableFutureUtil; +import com.discordsrv.common.logging.NamedLogger; +import com.discordsrv.common.module.type.AbstractModule; +import com.discordsrv.common.profile.Profile; +import com.discordsrv.common.sync.enums.SyncDirection; +import com.discordsrv.common.sync.enums.SyncResults; +import com.discordsrv.common.sync.enums.SyncSide; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.function.Consumer; + +public abstract class AbstractSyncModule
, S> extends AbstractModule
{ + + protected final Map> syncs = new LinkedHashMap<>(); + private final Map> configsForGame = new ConcurrentHashMap<>(); + private final Map> configsForDiscord = new ConcurrentHashMap<>(); + + public AbstractSyncModule(DT discordSRV, String loggerName) { + super(discordSRV, new NamedLogger(discordSRV, loggerName)); + } + + public abstract List configs(); + + @Override + public void reload(Consumer resultConsumer) { + synchronized (syncs) { + syncs.values().forEach(future -> { + if (future != null) { + future.cancel(false); + } + }); + syncs.clear(); + configsForGame.clear(); + configsForDiscord.clear(); + + for (C config : configs()) { + if (!config.isSet() || !config.validate(discordSRV)) { + continue; + } + + boolean failed = false; + for (C existingConfig : syncs.keySet()) { + if (existingConfig.isSameAs(config)) { + failed = true; + break; + } + } + if (failed) { + discordSRV.logger().error("Duplicate " + config.describe()); + continue; + } + + Future future = null; + GroupSyncConfig.PairConfig.TimerConfig timer = config.timer; + if (timer != null && timer.enabled) { + int cycleTime = timer.cycleTime; + future = discordSRV.scheduler().runAtFixedRate( + () -> resyncTimer(config), + Duration.ofMinutes(cycleTime), + Duration.ofMinutes(cycleTime) + ); + } + + syncs.put(config, future); + + G game = config.gameId(); + if (game != null) { + configsForGame.computeIfAbsent(game, key -> new ArrayList<>()).add(config); + } + + D discord = config.discordId(); + if (discord != null) { + configsForDiscord.computeIfAbsent(discord, key -> new ArrayList<>()).add(config); + } + } + } + } + + protected CompletableFuture lookupLinkedAccount(UUID player) { + return discordSRV.profileManager().lookupProfile(player) + .thenApply(Profile::userId) + .thenApply(userId -> { + if (userId == null) { + throw new SyncFail(SyncResults.NOT_LINKED); + } + return userId; + }); + } + + protected CompletableFuture lookupLinkedAccount(long userId) { + return discordSRV.profileManager().lookupProfile(userId) + .thenApply(Profile::playerUUID) + .thenApply(playerUUID -> { + if (playerUUID == null) { + throw new SyncFail(SyncResults.NOT_LINKED); + } + return playerUUID; + }); + } + + protected abstract boolean isTrue(S state); + + protected abstract CompletableFuture getDiscord(C config, long userId); + protected abstract CompletableFuture getGame(C config, UUID playerUUID); + + protected abstract CompletableFuture applyDiscord(C config, long userId, S state); + protected abstract CompletableFuture applyGame(C config, UUID playerUUID, S state); + + protected CompletableFuture applyDiscordIfNot(C config, long userId, S state) { + return getDiscord(config, userId).thenCompose(value -> { + boolean actualValue; + if ((actualValue = isTrue(state)) == isTrue(value)) { + return CompletableFuture.completedFuture(actualValue ? SyncResults.BOTH_TRUE : SyncResults.BOTH_FALSE); + } else { + return applyDiscord(config, userId, state); + } + }); + } + + protected CompletableFuture applyGameIfNot(C config, UUID playerUUID, S state) { + return getGame(config, playerUUID).thenCompose(value -> { + boolean actualValue; + if ((actualValue = isTrue(state)) == isTrue(value)) { + return CompletableFuture.completedFuture(actualValue ? SyncResults.BOTH_TRUE : SyncResults.BOTH_FALSE); + } else { + return applyGame(config, playerUUID, state); + } + }); + } + + protected Map> discordChanged(long userId, UUID playerUUID, D discordId, S state) { + List gameConfigs = configsForDiscord.get(discordId); + if (gameConfigs == null) { + return Collections.emptyMap(); + } + + Map> futures = new LinkedHashMap<>(); + for (C config : gameConfigs) { + SyncDirection direction = config.direction; + if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { + // Not going Discord -> Minecraft + futures.put(config, CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION)); + continue; + } + + futures.put(config, applyGameIfNot(config, playerUUID, state)); + + // If the sync is bidirectional, also sync anything else linked to the same Minecraft id + if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { + continue; + } + + List discordConfigs = configsForGame.get(config.gameId()); + if (discordConfigs == null) { + continue; + } + + for (C gameConfig : discordConfigs) { + if (gameConfig.discordId() == discordId) { + continue; + } + + futures.put(gameConfig, applyDiscordIfNot(gameConfig, userId, state)); + } + } + return futures; + } + + protected Map> gameChanged(long userId, UUID playerUUID, G gameId, S state) { + List discordConfigs = configsForGame.get(gameId); + if (discordConfigs == null) { + return Collections.emptyMap(); + } + + Map> futures = new LinkedHashMap<>(); + for (C config : discordConfigs) { + SyncDirection direction = config.direction; + if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { + // Not going Minecraft -> Discord + futures.put(config, CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION)); + continue; + } + + futures.put(config, applyDiscordIfNot(config, userId, state)); + + // If the sync is bidirectional, also sync anything else linked to the same Discord id + if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { + continue; + } + + List gameConfigs = configsForDiscord.get(config.discordId()); + if (gameConfigs == null) { + continue; + } + + for (C gameConfig : gameConfigs) { + if (gameConfig.gameId() == gameId) { + continue; + } + + futures.put(gameConfig, applyGameIfNot(gameConfig, playerUUID, state)); + } + } + return futures; + } + + protected abstract void resyncTimer(C config); + + protected CompletableFuture>> resyncAll(UUID playerUUID) { + return lookupLinkedAccount(playerUUID).thenApply(userId -> resyncAll(playerUUID, userId)); + } + + protected CompletableFuture>> resyncAll(long userId) { + return lookupLinkedAccount(userId).thenApply(playerUUID -> resyncAll(playerUUID, userId)); + } + + protected Map> resyncAll(UUID playerUUID, long userId) { + List configs = configs(); + + Map> results = new HashMap<>(configs.size()); + for (C config : configs) { + results.put(config, resync(config, playerUUID, userId)); + } + return results; + } + + protected CompletableFuture resync(C config, UUID playerUUID, long userId) { + CompletableFuture gameGet = getGame(config, playerUUID); + CompletableFuture discordGet = getDiscord(config, userId); + + return CompletableFutureUtil.combine(gameGet, discordGet).thenCompose((__) -> { + S gameState = gameGet.join(); + S discordState = discordGet.join(); + + boolean bothState; + if ((bothState = (gameState != null)) == (discordState != null)) { + // Already in sync + return CompletableFuture.completedFuture((ISyncResult) (bothState ? SyncResults.BOTH_TRUE : SyncResults.BOTH_FALSE)); + } + + SyncSide side = config.tieBreaker; + SyncDirection direction = config.direction; + if (discordState != null) { + if (side == SyncSide.DISCORD) { + // Has Discord, add game + if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { + return CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION); + } + + return applyGame(config, playerUUID, discordState).thenApply(v -> SyncResults.ADD_GAME); + } else { + // Missing game, remove Discord + if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { + return CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION); + } + + return applyDiscord(config, userId, null).thenApply(v -> SyncResults.REMOVE_DISCORD); + } + } else { + if (side == SyncSide.DISCORD) { + // Missing Discord, remove game + if (direction == SyncDirection.MINECRAFT_TO_DISCORD) { + return CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION); + } + + return applyGame(config, playerUUID, null).thenApply(v -> SyncResults.REMOVE_GAME); + } else { + // Has game, add Discord + if (direction == SyncDirection.DISCORD_TO_MINECRAFT) { + return CompletableFuture.completedFuture(SyncResults.WRONG_DIRECTION); + } + + return applyDiscord(config, userId, gameState).thenApply(v -> SyncResults.ADD_DISCORD); + } + } + }).exceptionally(t -> { + if (t instanceof SyncFail) { + return ((SyncFail) t).getResult(); + } else { + throw (RuntimeException) t; + } + }); + } + + +} diff --git a/common/src/main/java/com/discordsrv/common/sync/ISyncResult.java b/common/src/main/java/com/discordsrv/common/sync/ISyncResult.java new file mode 100644 index 00000000..fb972f64 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/sync/ISyncResult.java @@ -0,0 +1,7 @@ +package com.discordsrv.common.sync; + +public interface ISyncResult { + + boolean isSuccess(); + +} diff --git a/common/src/main/java/com/discordsrv/common/sync/SyncFail.java b/common/src/main/java/com/discordsrv/common/sync/SyncFail.java new file mode 100644 index 00000000..24b6bc55 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/sync/SyncFail.java @@ -0,0 +1,19 @@ +package com.discordsrv.common.sync; + +public class SyncFail extends RuntimeException { + + private final ISyncResult result; + + public SyncFail(ISyncResult result) { + this(result, null); + } + + public SyncFail(ISyncResult result, Throwable cause) { + super(cause); + this.result = result; + } + + public ISyncResult getResult() { + return result; + } +} diff --git a/common/src/main/java/com/discordsrv/common/sync/enums/SyncResults.java b/common/src/main/java/com/discordsrv/common/sync/enums/SyncResults.java new file mode 100644 index 00000000..232df6ee --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/sync/enums/SyncResults.java @@ -0,0 +1,43 @@ +package com.discordsrv.common.sync.enums; + +import com.discordsrv.common.sync.ISyncResult; + +public enum SyncResults implements ISyncResult { + + // Change made + ADD_GAME("Add game"), + REMOVE_GAME("Remove game"), + ADD_DISCORD("Add Discord"), + REMOVE_DISCORD("Remove Discord"), + + // Nothing happened + BOTH_TRUE("Both sides true"), + BOTH_FALSE("Both sides false"), + WRONG_DIRECTION("Wrong direction"), + + NOT_LINKED("Accounts not linked"), + + ; + + private final String prettyResult; + private final boolean success; + + SyncResults(String prettyResult) { + this(prettyResult, true); + } + + SyncResults(String prettyResult, boolean success) { + this.prettyResult = prettyResult; + this.success = success; + } + + @Override + public boolean isSuccess() { + return success; + } + + @Override + public String toString() { + return prettyResult; + } +}