diff --git a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java index 644c0df..46e17ba 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java +++ b/backend/src/main/java/com/alttd/altitudeweb/controllers/forms/AppealController.java @@ -37,6 +37,7 @@ public class AppealController implements AppealsApi { private final AppealDataMapper mapper; private final AppealMail appealMail; + private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord; @RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal") @Override @@ -66,6 +67,12 @@ public class AppealController implements AppealsApi { if (history == null) { throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found"); } + // Send to Discord channels + try { + appealDiscord.sendAppealToDiscord(appeal, history); + } catch (Exception e) { + log.error("Failed to send appeal {} to Discord", appeal.id(), e); + } CompletableFuture> emailVerificationCompletableFuture = new CompletableFuture<>(); Connection.getConnection(Databases.DEFAULT) diff --git a/backend/src/main/java/com/alttd/altitudeweb/services/discord/AppealDiscord.java b/backend/src/main/java/com/alttd/altitudeweb/services/discord/AppealDiscord.java new file mode 100644 index 0000000..08a0430 --- /dev/null +++ b/backend/src/main/java/com/alttd/altitudeweb/services/discord/AppealDiscord.java @@ -0,0 +1,113 @@ +package com.alttd.altitudeweb.services.discord; + +import com.alttd.altitudeweb.database.Databases; +import com.alttd.altitudeweb.database.discord.OutputChannel; +import com.alttd.altitudeweb.database.discord.OutputChannelMapper; +import com.alttd.altitudeweb.database.litebans.HistoryCountMapper; +import com.alttd.altitudeweb.database.litebans.HistoryRecord; +import com.alttd.altitudeweb.database.litebans.HistoryType; +import com.alttd.altitudeweb.database.litebans.UserType; +import com.alttd.altitudeweb.database.web_db.forms.Appeal; +import com.alttd.altitudeweb.setup.Connection; +import com.alttd.webinterface.bot.DiscordSender; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; + +@Slf4j +@Service +public class AppealDiscord { + + private static final String OUTPUT_TYPE = "EVIDENCE"; + + public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) { + // Fetch channels + CompletableFuture> channelsFuture = new CompletableFuture<>(); + Connection.getConnection(Databases.DISCORD).runQuery(sql -> { + try { + List channels = sql.getMapper(OutputChannelMapper.class) + .getChannelsWithOutputType(OUTPUT_TYPE); + channelsFuture.complete(channels); + } catch (Exception e) { + log.error("Failed to load output channels for {}", OUTPUT_TYPE, e); + channelsFuture.complete(new ArrayList<>()); + } + }); + + // Fetch counts for user + CompletableFuture bansF = getCountAsync(HistoryType.BAN, appeal.uuid()); + CompletableFuture mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid()); + CompletableFuture warnsF = getCountAsync(HistoryType.WARN, appeal.uuid()); + CompletableFuture kicksF = getCountAsync(HistoryType.KICK, appeal.uuid()); + + List channels = channelsFuture.join(); + int bans = bansF.join(); + int mutes = mutesF.join(); + int warns = warnsF.join(); + int kicks = kicksF.join(); + + if (channels.isEmpty()) { + log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE); + return; + } + + String message = buildMessage(appeal, history, bans, mutes, warns, kicks); + + List channelIds = channels.stream() + .map(OutputChannel::channel) + .filter(Objects::nonNull) + .toList(); + + DiscordSender.getInstance().sendMessageToChannels(channelIds, message); + } + + private CompletableFuture getCountAsync(HistoryType type, java.util.UUID uuid) { + CompletableFuture future = new CompletableFuture<>(); + Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> { + try { + Integer count = sql.getMapper(HistoryCountMapper.class) + .getUuidPunishmentCount(type, UserType.PLAYER, uuid); + future.complete(count == null ? 0 : count); + } catch (Exception e) { + log.error("Failed to load punishment count for {} ({})", type, uuid, e); + future.complete(0); + } + }); + return future; + } + + private String buildMessage(Appeal appeal, HistoryRecord history, int bans, int mutes, int warns, int kicks) { + boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis(); + String createdAt = formatInstant(appeal.createdAt()); + return new StringBuilder() + .append("New Appeal Submitted\n") + .append("Username: ").append(appeal.username()).append("\n") + .append("UUID: ").append(appeal.uuid()).append("\n") + .append("Email: ").append(appeal.email()).append("\n") + .append("Submitted at: ").append(createdAt).append("\n") + .append("Punishment type: ").append(appeal.historyType()).append("\n") + .append("Punishment id: ").append(appeal.historyId()).append("\n") + .append("Reason: ").append(history.getReason()).append("\n") + .append("Active: ").append(active).append("\n\n") + .append("Appeal:\n").append(appeal.reason()).append("\n\n") + .append("Previous punishments:\n") + .append("- Bans: ").append(bans).append("\n") + .append("- Mutes: ").append(mutes).append("\n") + .append("- Warnings: ").append(warns).append("\n") + .append("- Kicks: ").append(kicks) + .toString(); + } + + private String formatInstant(Instant instant) { + if (instant == null) return "unknown"; + return instant.atZone(ZoneId.of("UTC")) + .format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")); + } +} diff --git a/discord/src/main/java/com/alttd/webinterface/bot/DiscordBotInstance.java b/discord/src/main/java/com/alttd/webinterface/bot/DiscordBotInstance.java index fe9f5db..bc74ecd 100644 --- a/discord/src/main/java/com/alttd/webinterface/bot/DiscordBotInstance.java +++ b/discord/src/main/java/com/alttd/webinterface/bot/DiscordBotInstance.java @@ -1,22 +1,34 @@ package com.alttd.webinterface.bot; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.requests.GatewayIntent; +@Slf4j public class DiscordBotInstance { @Getter private JDA jda; + private volatile boolean ready = false; - public void start(String token) { + public synchronized void start(String token) { + if (jda != null) { + return; + } jda = JDABuilder.createDefault(token, GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_PRESENCES, GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT) - .build(); + .addEventListeners(new ReadyListener(() -> { + ready = true; + })) + .build(); } + public boolean isReady() { + return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED; + } } diff --git a/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java b/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java new file mode 100644 index 0000000..bc6d271 --- /dev/null +++ b/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java @@ -0,0 +1,64 @@ +package com.alttd.webinterface.bot; + +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@Slf4j +public class DiscordSender { + + private static final DiscordSender INSTANCE = new DiscordSender(); + + private final DiscordBotInstance botInstance = new DiscordBotInstance(); + + private DiscordSender() {} + + public static DiscordSender getInstance() { + return INSTANCE; + } + + private void ensureStarted() { + if (botInstance.getJda() != null) return; + String token = Optional.ofNullable(System.getenv("DISCORD_TOKEN")) + .orElse(System.getProperty("DISCORD_TOKEN")); + if (token == null || token.isBlank()) { + log.error("Discord token not found. Set DISCORD_TOKEN as an environment variable or system property."); + return; + } + botInstance.start(token); + } + + public void sendMessageToChannels(List channelIds, String message) { + ensureStarted(); + if (botInstance.getJda() == null) { + log.error("JDA not initialized; cannot send Discord message."); + return; + } + try { + if (!botInstance.isReady()) { + botInstance.getJda().awaitReady(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.warn("Error while waiting for JDA ready state", e); + } + + channelIds.stream() + .filter(Objects::nonNull) + .forEach(id -> { + TextChannel channel = botInstance.getJda().getChannelById(TextChannel.class, id); + if (channel == null) { + log.warn("TextChannel with id {} not found", id); + return; + } + channel.sendMessage(message).queue( + success -> log.debug("Sent message to channel {}", id), + error -> log.error("Failed sending message to channel {}", id, error) + ); + }); + } +} diff --git a/discord/src/main/java/com/alttd/webinterface/bot/ReadyListener.java b/discord/src/main/java/com/alttd/webinterface/bot/ReadyListener.java new file mode 100644 index 0000000..a9583a1 --- /dev/null +++ b/discord/src/main/java/com/alttd/webinterface/bot/ReadyListener.java @@ -0,0 +1,26 @@ +package com.alttd.webinterface.bot; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.events.session.ReadyEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +@Slf4j +@RequiredArgsConstructor +public class ReadyListener extends ListenerAdapter { + + private final Runnable onReadyCallback; + + @Override + public void onReady(@NotNull ReadyEvent event) { + log.info("JDA is ready. Guilds loaded: {}", event.getJDA().getGuilds().size()); + if (onReadyCallback != null) { + try { + onReadyCallback.run(); + } catch (Exception e) { + log.error("Error running onReady callback", e); + } + } + } +}