Add embed message support to Discord bot and update appeal flow to use embeds for Discord notifications

This commit is contained in:
akastijn 2025-08-24 00:43:58 +02:00
parent ffddffa8dc
commit eab1c9322b
6 changed files with 187 additions and 95 deletions

View File

@ -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<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {

View File

@ -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<DiscordSender.EmbedField> 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<Long> 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<Integer> 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) {

View File

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

View File

@ -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<Long> 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<Long> channelIds, String title, String description, List<EmbedField> 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;
}
}

View File

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

View File

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