AbstractSyncModule

This commit is contained in:
Vankka 2024-04-13 23:38:02 +03:00
parent 53e216ea2a
commit eec194491e
No known key found for this signature in database
GPG Key ID: 6E50CB7A29B96AD0
20 changed files with 1099 additions and 750 deletions

View File

@ -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<Void> addMute(@NotNull UUID playerUUID, @Nullable Instant until, @Nullable String reason, @NotNull String punisher);
CompletableFuture<Void> 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;
}
}
}

View File

@ -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;
}
}

View File

@ -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<BukkitDiscordSRV> 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<Punishment> getBan(@NotNull UUID playerUUID) {
public CompletableFuture<com.discordsrv.api.punishment.Punishment> getBan(@NotNull UUID playerUUID) {
CompletableFuture<BanEntry> entryFuture;
if (PaperBanList.IS_AVAILABLE) {
entryFuture = CompletableFuture.completedFuture(PaperBanList.getBanEntry(discordSRV.server(), playerUUID));
@ -52,7 +53,7 @@ public class BukkitBanModule extends AbstractModule<BukkitDiscordSRV> 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());
});
}

View File

@ -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<Punishment> getMute(@NotNull UUID playerUUID) {
public CompletableFuture<com.discordsrv.api.punishment.Punishment> getMute(@NotNull UUID playerUUID) {
return getUser(playerUUID).thenApply(user -> new Punishment(Instant.ofEpochMilli(user.getMuteTimeout()), user.getMuteReason(), null));
}

View File

@ -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);

View File

@ -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<DiscordSRV> {
private final Map<Long, PunishmentEvent> 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<DiscordGatewayIntent> 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<Long> lookupLinkedAccount(UUID player) {
return discordSRV.profileManager().lookupProfile(player)
.thenApply(Profile::userId);
@ -39,70 +74,203 @@ public class BanSyncModule extends AbstractModule<DiscordSRV> {
.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<Guild.@Nullable Ban> 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<BanSyncResult> 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<BanSyncResult> changeUserBanState(long userId, boolean newState, @Nullable String reason) {
public CompletableFuture<BanSyncResult> resync(long userId) {
return lookupLinkedAccount(userId).thenCompose(playerUUID -> {
if (playerUUID == null) {
// Unlinked
return null;
}
return resync(playerUUID, userId);
});
}
public CompletableFuture<BanSyncResult> 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<BanSyncResult> 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<BanSyncResult> 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<DiscordSRV> {
// 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<BanSyncResult> changePlayerBanState(UUID playerUUID, boolean newState, @Nullable String reason) {
private CompletableFuture<BanSyncResult> 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);
}
}
});
}
}

View File

@ -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;
}
}

View File

@ -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<GroupSyncResult, AtomicInteger> resultCounts = new EnumMap<>(GroupSyncResult.class);
int total = 0;
for (List<GroupSyncResult> 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<CompletableFuture<ISyncResult>> results = new ArrayList<>();
for (Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>> map : result) {
results.addAll(map.values());
}
return CompletableFutureUtil.combine(results);
}).whenComplete((results, t) -> {
Map<ISyncResult, AtomicInteger> 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<CompletableFuture<List<GroupSyncResult>>> resyncOnlinePlayers(GroupSyncModule module) {
List<CompletableFuture<List<GroupSyncResult>>> futures = new ArrayList<>();
private List<CompletableFuture<Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>>>> resyncOnlinePlayers(GroupSyncModule module) {
List<CompletableFuture<Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>>>> futures = new ArrayList<>();
for (IPlayer player : discordSRV.playerProvider().allPlayers()) {
futures.add(module.resync(player.uniqueId(), GroupSyncCause.COMMAND));
}

View File

@ -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<BanSyncConfig, Void, Long> {
@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";
}
}

View File

@ -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<PairConfig> pairs = new ArrayList<>(Collections.singletonList(new PairConfig()));
@ConfigSerializable
public static class PairConfig {
public static class PairConfig extends AbstractSyncConfig<PairConfig, String, Long> {
@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<String> serverContext) {
return groupName + (serverContext != null ? String.join(" ", serverContext) : "");
}
}
}

View File

@ -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();

View File

@ -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<C extends AbstractSyncConfig<C, G, D>, 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;
}
}

View File

@ -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()));

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<DiscordSRV> {
private final Map<GroupSyncConfig.PairConfig, Future<?>> pairs = new LinkedHashMap<>();
private final Map<String, List<GroupSyncConfig.PairConfig>> groupsToPairs = new ConcurrentHashMap<>();
private final Map<Long, List<GroupSyncConfig.PairConfig>> rolesToPairs = new ConcurrentHashMap<>();
public class GroupSyncModule extends AbstractSyncModule<DiscordSRV, String, Long, GroupSyncConfig.PairConfig, Boolean> {
private final Cache<Long, Map<Long, Boolean>> expectedDiscordChanges;
private final Cache<UUID, Map<String, Boolean>> 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<GroupSyncConfig.PairConfig> 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<DiscordSRV> {
return super.isEnabled();
}
@Override
public void reload(Consumer<DiscordSRVApi.ReloadResult> 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<GroupSyncConfig.PairConfig, Future<?>> entry : pairs.entrySet()) {
for (Map.Entry<GroupSyncConfig.PairConfig, Future<?>> 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<DiscordSRV> {
}
private void logSummary(
UUID player,
UUID playerUUID,
GroupSyncCause cause,
Map<GroupSyncConfig.PairConfig, CompletableFuture<GroupSyncResult>> pairs
CompletableFuture<Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>>> 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<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>> pairs
) {
CompletableFutureUtil.combine(pairs.values()).whenComplete((v, t) -> {
GroupSyncSummary summary = new GroupSyncSummary(player, cause);
for (Map.Entry<GroupSyncConfig.PairConfig, CompletableFuture<GroupSyncResult>> entry : pairs.entrySet()) {
GroupSyncSummary summary = new GroupSyncSummary(playerUUID, cause);
for (Map.Entry<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>> entry : pairs.entrySet()) {
summary.add(entry.getKey(), entry.getValue().join());
}
@ -191,507 +134,245 @@ public class GroupSyncModule extends AbstractModule<DiscordSRV> {
});
}
// Linked account helper methods
private CompletableFuture<Long> lookupLinkedAccount(UUID player) {
return discordSRV.profileManager().lookupProfile(player)
.thenApply(Profile::userId);
}
private CompletableFuture<UUID> 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<Boolean> 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<Void> 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<Void> 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<List<GroupSyncResult>> 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<List<GroupSyncResult>> 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<CompletableFuture<GroupSyncResult>> 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<GroupSyncConfig.PairConfig, CompletableFuture<GroupSyncResult>> 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<GroupSyncResult> 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<GroupSyncResult> 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<Void> 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<String> serverContext, GroupSyncCause cause) {
groupChanged(player, groupName, serverContext, cause, false);
}
public void groupRemoved(UUID player, String groupName, @Nullable Set<String> serverContext, GroupSyncCause cause) {
groupChanged(player, groupName, serverContext, cause, true);
}
// Internal handling of changes
public void groupRemoved(UUID player, String groupName, @Nullable Set<String> serverContext, GroupSyncCause cause) {
groupChanged(player, groupName, serverContext, cause, false);
}
private <T, R> boolean checkExpectation(Cache<T, Map<R, Boolean>> 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<String> 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 <T, R> boolean checkExpectation(Cache<T, Map<R, Boolean>> 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<R, Boolean> 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<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>> 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<GroupSyncConfig.PairConfig> 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<GroupSyncConfig.PairConfig, CompletableFuture<GroupSyncResult>> 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<GroupSyncConfig.PairConfig> 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<String> 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<String> serverContext,
GroupSyncCause cause,
boolean remove
) {
List<GroupSyncConfig.PairConfig> pairs = groupsToPairs.get(groupName);
if (pairs == null) {
return;
}
PermissionModule.Groups permissionProvider = getPermissionProvider();
Map<GroupSyncConfig.PairConfig, CompletableFuture<GroupSyncResult>> 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<String> 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<GroupSyncConfig.PairConfig> 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<GroupSyncResult> modifyGroupState(UUID player, GroupSyncConfig.PairConfig config, boolean remove) {
String groupName = config.groupName;
Map<String, Boolean> expected = expectedMinecraftChanges.get(player, key -> new ConcurrentHashMap<>());
if (expected != null) {
expected.put(groupName, remove);
}
CompletableFuture<GroupSyncResult> 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<Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>>> resync(UUID playerUUID, GroupSyncCause cause) {
CompletableFuture<Map<GroupSyncConfig.PairConfig, CompletableFuture<ISyncResult>>> future = resyncAll(playerUUID);
logSummary(playerUUID, cause, future);
return future;
}
private CompletableFuture<GroupSyncResult> modifyRoleState(long userId, GroupSyncConfig.PairConfig config, boolean remove) {
long roleId = config.roleId;
DiscordRole role = discordSRV.discordAPI().getRoleById(roleId);
@Override
public CompletableFuture<Boolean> 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<Long, Boolean> expected = expectedDiscordChanges.get(userId, key -> new ConcurrentHashMap<>());
if (expected != null) {
expected.put(roleId, remove);
}
boolean hasRole = member.hasRole(role);
CompletableFuture<GroupSyncResult> 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<GroupSyncResult> 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<Boolean> getGame(GroupSyncConfig.PairConfig config, UUID playerUUID) {
PermissionModule.Groups permissionProvider = getPermissionProvider();
CompletableFuture<Boolean> 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<ISyncResult> 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<Long, Boolean> 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<ISyncResult> applyGame(GroupSyncConfig.PairConfig config, UUID playerUUID, Boolean state) {
Map<String, Boolean> expected = expectedMinecraftChanges.get(playerUUID, key -> new ConcurrentHashMap<>());
if (expected != null) {
expected.put(config.groupName, state);
}
CompletableFuture<ISyncResult> 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<String> context(GroupSyncConfig.PairConfig config) {
return config.serverContext != null ? Collections.singleton(config.serverContext) : null;
}
private String context(String groupName, Set<String> 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<Void> 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<Void> 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);
}
}
}

View File

@ -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<GroupSyncResult, Set<GroupSyncConfig.PairConfig>> pairs = new EnumMap<>(GroupSyncResult.class);
private final Map<ISyncResult, Set<GroupSyncConfig.PairConfig>> 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<GroupSyncResult, Set<GroupSyncConfig.PairConfig>> entry : pairs.entrySet()) {
for (Map.Entry<ISyncResult, Set<GroupSyncConfig.PairConfig>> entry : pairs.entrySet()) {
message.append(count == 1 ? ": " : "\n")
.append(entry.getKey().toString())
.append(": ")

View File

@ -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"),

View File

@ -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<DT extends DiscordSRV, G, D, C extends AbstractSyncConfig<C, G, D>, S> extends AbstractModule<DT> {
protected final Map<C, Future<?>> syncs = new LinkedHashMap<>();
private final Map<G, List<C>> configsForGame = new ConcurrentHashMap<>();
private final Map<D, List<C>> configsForDiscord = new ConcurrentHashMap<>();
public AbstractSyncModule(DT discordSRV, String loggerName) {
super(discordSRV, new NamedLogger(discordSRV, loggerName));
}
public abstract List<C> configs();
@Override
public void reload(Consumer<DiscordSRVApi.ReloadResult> 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<Long> 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<UUID> 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<S> getDiscord(C config, long userId);
protected abstract CompletableFuture<S> getGame(C config, UUID playerUUID);
protected abstract CompletableFuture<ISyncResult> applyDiscord(C config, long userId, S state);
protected abstract CompletableFuture<ISyncResult> applyGame(C config, UUID playerUUID, S state);
protected CompletableFuture<ISyncResult> 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<ISyncResult> 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<C, CompletableFuture<ISyncResult>> discordChanged(long userId, UUID playerUUID, D discordId, S state) {
List<C> gameConfigs = configsForDiscord.get(discordId);
if (gameConfigs == null) {
return Collections.emptyMap();
}
Map<C, CompletableFuture<ISyncResult>> 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<C> 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<C, CompletableFuture<ISyncResult>> gameChanged(long userId, UUID playerUUID, G gameId, S state) {
List<C> discordConfigs = configsForGame.get(gameId);
if (discordConfigs == null) {
return Collections.emptyMap();
}
Map<C, CompletableFuture<ISyncResult>> 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<C> 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<Map<C, CompletableFuture<ISyncResult>>> resyncAll(UUID playerUUID) {
return lookupLinkedAccount(playerUUID).thenApply(userId -> resyncAll(playerUUID, userId));
}
protected CompletableFuture<Map<C, CompletableFuture<ISyncResult>>> resyncAll(long userId) {
return lookupLinkedAccount(userId).thenApply(playerUUID -> resyncAll(playerUUID, userId));
}
protected Map<C, CompletableFuture<ISyncResult>> resyncAll(UUID playerUUID, long userId) {
List<C> configs = configs();
Map<C, CompletableFuture<ISyncResult>> results = new HashMap<>(configs.size());
for (C config : configs) {
results.put(config, resync(config, playerUUID, userId));
}
return results;
}
protected CompletableFuture<ISyncResult> resync(C config, UUID playerUUID, long userId) {
CompletableFuture<S> gameGet = getGame(config, playerUUID);
CompletableFuture<S> 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;
}
});
}
}

View File

@ -0,0 +1,7 @@
package com.discordsrv.common.sync;
public interface ISyncResult {
boolean isSuccess();
}

View File

@ -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;
}
}

View File

@ -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;
}
}