From eab1c9322b86f10d18c29fed656450cd73390c68 Mon Sep 17 00:00:00 2001 From: akastijn Date: Sun, 24 Aug 2025 00:43:58 +0200 Subject: [PATCH] Add embed message support to Discord bot and update appeal flow to use embeds for Discord notifications --- .../controllers/forms/AppealController.java | 2 + .../services/discord/AppealDiscord.java | 75 ++++++---- .../alttd/webinterface/bot/DiscordSender.java | 64 --------- .../send_message/DiscordSender.java | 133 ++++++++++++++++++ .../pages/forms/appeal/appeal.component.ts | 2 +- .../app/pages/forms/sent/sent.component.ts | 6 +- 6 files changed, 187 insertions(+), 95 deletions(-) delete mode 100644 discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java create mode 100644 discord/src/main/java/com/alttd/webinterface/send_message/DiscordSender.java 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 46e17ba..b089301 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 @@ -74,6 +74,8 @@ public class AppealController implements AppealsApi { log.error("Failed to send appeal {} to Discord", appeal.id(), e); } + appealMail.sendAppealNotification(appeal, history); + CompletableFuture> emailVerificationCompletableFuture = new CompletableFuture<>(); Connection.getConnection(Databases.DEFAULT) .runQuery(sqlSession -> { 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 index 08a0430..9d0b99a 100644 --- a/backend/src/main/java/com/alttd/altitudeweb/services/discord/AppealDiscord.java +++ b/backend/src/main/java/com/alttd/altitudeweb/services/discord/AppealDiscord.java @@ -9,7 +9,7 @@ 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 com.alttd.webinterface.send_message.DiscordSender; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -18,14 +18,13 @@ 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"; + private static final String OUTPUT_TYPE = "APPEAL"; public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) { // Fetch channels @@ -58,14 +57,56 @@ public class AppealDiscord { return; } - String message = buildMessage(appeal, history, bans, mutes, warns, kicks); + // Build embed + boolean active = history.getUntil() == null || history.getUntil() <= 0 || history.getUntil() > System.currentTimeMillis(); + String createdAt = formatInstant(appeal.createdAt()); + + List fields = new ArrayList<>(); + // Group: User + fields.add(new DiscordSender.EmbedField( + "User", + "Username: " + safe(appeal.username()) + "\n" + + "UUID: " + safe(String.valueOf(appeal.uuid())) + "\n" + + "Email: " + safe(appeal.email()) + "\n" + + "Submitted: " + createdAt, + false + )); + // Group: Punishment + fields.add(new DiscordSender.EmbedField( + "Punishment", + "Type: " + safe(String.valueOf(appeal.historyType())) + "\n" + + "ID: " + safe(String.valueOf(appeal.historyId())) + "\n" + + "Reason: " + safe(history.getReason()) + "\n" + + "Active: " + active, + false + )); + // Group: Previous punishments + fields.add(new DiscordSender.EmbedField( + "Previous punishments", + "Bans: " + bans + "\n" + + "Mutes: " + mutes + "\n" + + "Warnings: " + warns + "\n" + + "Kicks: " + kicks, + true + )); + + String description = safe(appeal.reason()); List channelIds = channels.stream() .map(OutputChannel::channel) - .filter(Objects::nonNull) .toList(); - DiscordSender.getInstance().sendMessageToChannels(channelIds, message); + // colorRgb = null (use default), timestamp = appeal.createdAt if available + Instant timestamp = appeal.createdAt() != null ? appeal.createdAt() : Instant.now(); + DiscordSender.getInstance().sendEmbedToChannels( + channelIds, + "New Appeal Submitted", + description, + fields, + null, + timestamp, + null + ); } private CompletableFuture getCountAsync(HistoryType type, java.util.UUID uuid) { @@ -83,26 +124,8 @@ public class AppealDiscord { 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 safe(String s) { + return s == null ? "unknown" : s; } private String formatInstant(Instant instant) { diff --git a/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java b/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java deleted file mode 100644 index bc6d271..0000000 --- a/discord/src/main/java/com/alttd/webinterface/bot/DiscordSender.java +++ /dev/null @@ -1,64 +0,0 @@ -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/send_message/DiscordSender.java b/discord/src/main/java/com/alttd/webinterface/send_message/DiscordSender.java new file mode 100644 index 0000000..5beb185 --- /dev/null +++ b/discord/src/main/java/com/alttd/webinterface/send_message/DiscordSender.java @@ -0,0 +1,133 @@ +package com.alttd.webinterface.send_message; + +import com.alttd.webinterface.bot.DiscordBotInstance; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +import java.awt.*; +import java.time.Instant; +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) + ); + }); + } + + public void sendEmbedToChannels(List channelIds, String title, String description, List fields, + Integer colorRgb, Instant timestamp, String footer) { + ensureStarted(); + if (botInstance.getJda() == null) { + log.error("JDA not initialized; cannot send Discord embed."); + 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); + } + + EmbedBuilder eb = new EmbedBuilder(); + if (title != null && !title.isBlank()) eb.setTitle(title); + if (description != null && !description.isBlank()) eb.setDescription(description); + if (colorRgb != null) eb.setColor(new Color(colorRgb)); else eb.setColor(new Color(0xFF8C00)); // default orange + eb.setTimestamp(timestamp != null ? timestamp : Instant.now()); + if (footer != null && !footer.isBlank()) eb.setFooter(footer); + + if (fields != null) { + for (EmbedField f : fields) { + if (f == null) continue; + String name = f.getName() == null ? "" : f.getName(); + String value = f.getValue() == null ? "" : f.getValue(); + // JDA field value max is 1024; truncate to be safe + if (value.length() > 1024) value = value.substring(0, 1021) + "..."; + eb.addField(new MessageEmbed.Field(name, value, f.isInline())); + } + } + + MessageEmbed embed = eb.build(); + + 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.sendMessageEmbeds(embed).queue( + success -> log.debug("Sent embed to channel {}", id), + error -> log.error("Failed sending embed to channel {}", id, error) + ); + }); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class EmbedField { + private String name; + private String value; + private boolean inline; + } +} diff --git a/frontend/src/app/pages/forms/appeal/appeal.component.ts b/frontend/src/app/pages/forms/appeal/appeal.component.ts index 6cb9e4c..c2186a0 100644 --- a/frontend/src/app/pages/forms/appeal/appeal.component.ts +++ b/frontend/src/app/pages/forms/appeal/appeal.component.ts @@ -187,7 +187,7 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit { if (!result.verified_mail) { throw new Error('Mail not verified'); } - this.router.navigate(['/form/sent'], { + this.router.navigate(['/forms/sent'], { state: {message: result.message} }).then(); }) diff --git a/frontend/src/app/pages/forms/sent/sent.component.ts b/frontend/src/app/pages/forms/sent/sent.component.ts index 42aef15..2f80569 100644 --- a/frontend/src/app/pages/forms/sent/sent.component.ts +++ b/frontend/src/app/pages/forms/sent/sent.component.ts @@ -1,4 +1,4 @@ -import {Component, OnInit} from '@angular/core'; +import {Component, inject, OnInit} from '@angular/core'; import {Router} from '@angular/router'; import {HeaderComponent} from '@header/header.component'; @@ -13,9 +13,7 @@ import {HeaderComponent} from '@header/header.component'; }) export class SentComponent implements OnInit { protected message: string = "The form is completed and has been sent"; - - constructor(private router: Router) { - } + private router: Router = inject(Router) ngOnInit() { const navigation = this.router.getCurrentNavigation();