Compare commits
2 Commits
0b4c1ccebf
...
eab1c9322b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eab1c9322b | ||
|
|
ffddffa8dc |
|
|
@ -37,6 +37,7 @@ public class AppealController implements AppealsApi {
|
||||||
|
|
||||||
private final AppealDataMapper mapper;
|
private final AppealDataMapper mapper;
|
||||||
private final AppealMail appealMail;
|
private final AppealMail appealMail;
|
||||||
|
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
|
||||||
|
|
||||||
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -66,6 +67,14 @@ public class AppealController implements AppealsApi {
|
||||||
if (history == null) {
|
if (history == null) {
|
||||||
throw new ResponseStatusException(HttpStatusCode.valueOf(404), "History not found");
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
appealMail.sendAppealNotification(appeal, history);
|
||||||
|
|
||||||
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
|
||||||
Connection.getConnection(Databases.DEFAULT)
|
Connection.getConnection(Databases.DEFAULT)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,136 @@
|
||||||
|
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.send_message.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.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AppealDiscord {
|
||||||
|
|
||||||
|
private static final String OUTPUT_TYPE = "APPEAL";
|
||||||
|
|
||||||
|
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
|
||||||
|
// Fetch channels
|
||||||
|
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
|
||||||
|
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
|
||||||
|
try {
|
||||||
|
List<OutputChannel> 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<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
|
||||||
|
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
|
||||||
|
CompletableFuture<Integer> warnsF = getCountAsync(HistoryType.WARN, appeal.uuid());
|
||||||
|
CompletableFuture<Integer> kicksF = getCountAsync(HistoryType.KICK, appeal.uuid());
|
||||||
|
|
||||||
|
List<OutputChannel> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
CompletableFuture<Integer> 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 safe(String s) {
|
||||||
|
return s == null ? "unknown" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)'"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,34 @@
|
||||||
package com.alttd.webinterface.bot;
|
package com.alttd.webinterface.bot;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import net.dv8tion.jda.api.JDA;
|
import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
import net.dv8tion.jda.api.JDABuilder;
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
public class DiscordBotInstance {
|
public class DiscordBotInstance {
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
private JDA jda;
|
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,
|
jda = JDABuilder.createDefault(token,
|
||||||
GatewayIntent.GUILD_MEMBERS,
|
GatewayIntent.GUILD_MEMBERS,
|
||||||
GatewayIntent.GUILD_PRESENCES,
|
GatewayIntent.GUILD_PRESENCES,
|
||||||
GatewayIntent.GUILD_MESSAGES,
|
GatewayIntent.GUILD_MESSAGES,
|
||||||
GatewayIntent.MESSAGE_CONTENT)
|
GatewayIntent.MESSAGE_CONTENT)
|
||||||
.build();
|
.addEventListeners(new ReadyListener(() -> {
|
||||||
|
ready = true;
|
||||||
|
}))
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isReady() {
|
||||||
|
return ready && jda != null && jda.getStatus() == JDA.Status.CONNECTED;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -187,7 +187,7 @@ export class AppealComponent implements OnInit, OnDestroy, AfterViewInit {
|
||||||
if (!result.verified_mail) {
|
if (!result.verified_mail) {
|
||||||
throw new Error('Mail not verified');
|
throw new Error('Mail not verified');
|
||||||
}
|
}
|
||||||
this.router.navigate(['/form/sent'], {
|
this.router.navigate(['/forms/sent'], {
|
||||||
state: {message: result.message}
|
state: {message: result.message}
|
||||||
}).then();
|
}).then();
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import {Component, OnInit} from '@angular/core';
|
import {Component, inject, OnInit} from '@angular/core';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {HeaderComponent} from '@header/header.component';
|
import {HeaderComponent} from '@header/header.component';
|
||||||
|
|
||||||
|
|
@ -13,9 +13,7 @@ import {HeaderComponent} from '@header/header.component';
|
||||||
})
|
})
|
||||||
export class SentComponent implements OnInit {
|
export class SentComponent implements OnInit {
|
||||||
protected message: string = "The form is completed and has been sent";
|
protected message: string = "The form is completed and has been sent";
|
||||||
|
private router: Router = inject(Router)
|
||||||
constructor(private router: Router) {
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
const navigation = this.router.getCurrentNavigation();
|
const navigation = this.router.getCurrentNavigation();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user