Skip to content

Commit debe757

Browse files
authored
GH-139 Introduce ticket management system with comman's (#202)
* GH-139 Introduce comprehensive ticket management system with commands, UI panel, and cleanup tasks. * refactor: Change autoCloseAfterHours to Duration type and update TicketWrapper ID field generation * refactor: Improve ticket management system with enhanced service methods, manual JDA command registration and better exception handling * Replace System.currentTimeMillis with Instant.now for ticket ID generation * refactor: Enhance ticket panel system with dynamic panel embed creation, URL validation utility, and improved configuration handling.
1 parent 64723f8 commit debe757

18 files changed

+1369
-4
lines changed

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ dependencies {
6666
implementation("org.apache.commons:commons-lang3:3.19.0")
6767

6868
implementation("com.eternalcode:eternalcode-commons-shared:1.3.1")
69+
70+
implementation("dev.skywolfxp:discord-channel-html-transcript:3.0.0")
6971
}
7072

7173
tasks.getByName<Test>("test") {

src/main/java/com/eternalcode/discordapp/DiscordApp.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
import com.eternalcode.discordapp.review.database.GitHubReviewMentionRepositoryImpl;
5151
import com.eternalcode.discordapp.scheduler.Scheduler;
5252
import com.eternalcode.discordapp.scheduler.VirtualThreadSchedulerImpl;
53+
import com.eternalcode.discordapp.ticket.TicketConfigurer;
5354
import com.jagrosh.jdautilities.command.CommandClient;
5455
import com.jagrosh.jdautilities.command.CommandClientBuilder;
5556
import io.sentry.Sentry;
@@ -137,10 +138,11 @@ private void runApplication() throws Exception {
137138
MeetingService meetingService = new MeetingService(appConfig, meetingPollRepository, meetingVoteRepository);
138139

139140
LOGGER.info("Building command client...");
140-
CommandClient commandClient = new CommandClientBuilder()
141+
CommandClientBuilder commandClientBuilder = new CommandClientBuilder()
141142
.setOwnerId(appConfig.topOwnerId)
142143
.setActivity(Activity.playing("IntelliJ IDEA"))
143144
.useHelpBuilder(false)
145+
.forceGuildOnly(appConfig.guildId)
144146
.addSlashCommands(
145147
new AvatarCommand(appConfig),
146148
new BanCommand(appConfig),
@@ -158,14 +160,13 @@ private void runApplication() throws Exception {
158160
new LevelCommand(levelService),
159161
new LeaderboardCommand(leaderboardService),
160162
new MeetingCommand(meetingService)
161-
)
162-
.build();
163+
);
163164

164165
LOGGER.info("Initializing Discord bot...");
165166
FilterService filterService = new FilterService().register(new RenovateForcedPushFilter());
167+
166168
JDA jda = JDABuilder.createDefault(appConfig.token)
167169
.addEventListeners(
168-
commandClient,
169170
new ExperienceMessageListener(experienceConfig, experienceService),
170171
new ExperienceReactionListener(experienceConfig, experienceService),
171172
new FilterMessageEmbedController(filterService),
@@ -189,6 +190,21 @@ private void runApplication() throws Exception {
189190
LOGGER.info("Initializing JDA-dependent services...");
190191
GuildStatisticsService guildStats = new GuildStatisticsService(appConfig, jda);
191192
AutoMessageService autoMsgService = new AutoMessageService(jda, appConfig.autoMessagesConfig);
193+
194+
TicketConfigurer ticketConfigurer = new TicketConfigurer(
195+
jda,
196+
configManager,
197+
databaseManager,
198+
scheduler,
199+
commandClientBuilder,
200+
appConfig
201+
);
202+
ticketConfigurer.initialize();
203+
204+
CommandClient commandClient = commandClientBuilder.build();
205+
LOGGER.info("CommandClient built and registered");
206+
jda.addEventListener(commandClient);
207+
192208
GitHubReviewReminderService reminderService = new GitHubReviewReminderService(
193209
jda,
194210
mentionRepo,

src/main/java/com/eternalcode/discordapp/config/AppConfig.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.eternalcode.discordapp.automessages.AutoMessagesConfig;
44
import com.eternalcode.discordapp.review.GitHubReviewNotificationType;
55
import com.eternalcode.discordapp.review.GitHubReviewUser;
6+
import com.eternalcode.discordapp.ticket.TicketConfig;
67
import net.dzikoysk.cdn.entity.Contextual;
78
import net.dzikoysk.cdn.entity.Description;
89
import net.dzikoysk.cdn.source.Resource;
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package com.eternalcode.discordapp.ticket;
2+
3+
import com.eternalcode.discordapp.util.UrlValidator;
4+
import java.awt.Color;
5+
import java.time.Instant;
6+
import java.util.List;
7+
import java.util.Optional;
8+
import java.util.concurrent.CompletableFuture;
9+
import java.util.concurrent.ThreadLocalRandom;
10+
import net.dv8tion.jda.api.EmbedBuilder;
11+
import net.dv8tion.jda.api.JDA;
12+
import net.dv8tion.jda.api.Permission;
13+
import net.dv8tion.jda.api.entities.Guild;
14+
import net.dv8tion.jda.api.entities.Member;
15+
import net.dv8tion.jda.api.entities.MessageEmbed;
16+
import net.dv8tion.jda.api.entities.Role;
17+
import net.dv8tion.jda.api.entities.User;
18+
import net.dv8tion.jda.api.entities.channel.concrete.Category;
19+
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
20+
import net.dv8tion.jda.api.interactions.components.buttons.Button;
21+
import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
22+
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
26+
public class TicketChannelService {
27+
28+
private static final Logger LOGGER = LoggerFactory.getLogger(TicketChannelService.class);
29+
30+
private final JDA jda;
31+
private final TicketConfig config;
32+
private final TicketService ticketService;
33+
private final TranscriptGeneratorService transcriptGeneratorService;
34+
35+
public TicketChannelService(
36+
JDA jda,
37+
TicketConfig config,
38+
TicketService ticketService,
39+
TranscriptGeneratorService transcriptGeneratorService
40+
) {
41+
this.jda = jda;
42+
this.config = config;
43+
this.ticketService = ticketService;
44+
this.transcriptGeneratorService = transcriptGeneratorService;
45+
}
46+
47+
public CompletableFuture<Optional<TextChannel>> createTicket(long userId, String categoryId) {
48+
TicketConfig.TicketCategoryConfig category = this.config.getCategoryById(categoryId);
49+
if (category == null || !category.enabled) {
50+
return CompletableFuture.completedFuture(Optional.empty());
51+
}
52+
53+
return this.ticketService.canUserCreateTicket(userId, categoryId)
54+
.thenCompose(canCreate -> {
55+
if (!canCreate) {
56+
return CompletableFuture.completedFuture(Optional.empty());
57+
}
58+
59+
return CompletableFuture.supplyAsync(() -> {
60+
try {
61+
long ticketId = Instant.now().toEpochMilli() + ThreadLocalRandom.current().nextInt(1000, 9999);
62+
TextChannel channel = this.createChannel(userId, category, ticketId);
63+
64+
TicketWrapper ticket = new TicketWrapper(userId, channel.getIdLong(), categoryId);
65+
this.ticketService.saveTicket(ticket).join();
66+
67+
channel.sendMessage(this.createWelcomeMessage(ticketId, userId, category)).queue();
68+
69+
LOGGER.info(
70+
"Created ticket #{} for user {} in channel {}",
71+
ticketId,
72+
userId,
73+
channel.getIdLong());
74+
return Optional.of(channel);
75+
}
76+
catch (Exception exception) {
77+
LOGGER.error("Failed to create ticket for user {}", userId, exception);
78+
return Optional.empty();
79+
}
80+
});
81+
});
82+
}
83+
84+
public CompletableFuture<Boolean> closeTicket(long channelId, long staffId, String reason) {
85+
return this.ticketService.getTicketByChannel(channelId).thenCompose(ticketOpt -> {
86+
if (ticketOpt.isEmpty()) {
87+
LOGGER.warn("Attempted to close non-existent ticket for channel {}", channelId);
88+
return CompletableFuture.completedFuture(false);
89+
}
90+
91+
TicketWrapper ticket = ticketOpt.get();
92+
93+
return this.ticketService.deleteTicket(ticket.getId()).thenApply(result -> {
94+
if (result > 0) {
95+
this.transcriptGeneratorService.generateAndSendTranscript(ticket, staffId, reason);
96+
97+
TextChannel channel = this.jda.getTextChannelById(channelId);
98+
if (channel != null) {
99+
channel.delete().queue(
100+
success -> LOGGER.info("Successfully closed and deleted ticket #{}", ticket.getId()),
101+
failure -> LOGGER.error(
102+
"Failed to delete channel {} for ticket #{}",
103+
channelId,
104+
ticket.getId(),
105+
failure)
106+
);
107+
}
108+
return true;
109+
}
110+
LOGGER.warn("Failed to delete ticket #{} from database", ticket.getId());
111+
return false;
112+
});
113+
}).exceptionally(exception -> {
114+
LOGGER.error("Error closing ticket for channel {}", channelId, exception);
115+
return false;
116+
});
117+
}
118+
119+
public CompletableFuture<Void> cleanupInactiveTickets() {
120+
Instant cutoffTime = Instant.now().minus(this.config.getAutoCloseDuration());
121+
122+
return this.ticketService.findTicketsOlderThan(cutoffTime)
123+
.thenCompose(tickets -> CompletableFuture.allOf(
124+
tickets.stream()
125+
.map(ticket -> this.closeTicket(
126+
ticket.getChannelId(),
127+
0L,
128+
"Automatically closed due to inactivity")
129+
.thenApply(result -> (Void) null))
130+
.toArray(CompletableFuture[]::new)
131+
));
132+
}
133+
134+
public MessageCreateData createWelcomeMessage(
135+
long ticketId,
136+
long userId,
137+
TicketConfig.TicketCategoryConfig category) {
138+
139+
Instant now = Instant.now();
140+
141+
return new MessageCreateBuilder()
142+
.setContent("<@" + userId + ">")
143+
.setEmbeds(new EmbedBuilder()
144+
.setTitle("🎫 Ticket #" + ticketId)
145+
.setDescription(this.config.messages.ticketCreated)
146+
.addField("📋 Category", category.displayName, true)
147+
.addField("👤 User", "<@" + userId + ">", true)
148+
.addField("📅 Created", "<t:" + now.getEpochSecond() + ":F>", true)
149+
.setColor(Color.decode(this.config.embeds.color))
150+
.setTimestamp(this.config.embeds.showTimestamp ? now : null)
151+
.build())
152+
.addActionRow(Button.danger("ticket_close", "🔒 Close"))
153+
.build();
154+
}
155+
156+
public MessageEmbed createEmbed(String title, String description) {
157+
EmbedBuilder builder = new EmbedBuilder()
158+
.setTitle(title)
159+
.setDescription(description)
160+
.setColor(Color.decode(this.config.embeds.color));
161+
162+
if (UrlValidator.isValid(this.config.embeds.thumbnail)) {
163+
builder.setThumbnail(this.config.embeds.thumbnail);
164+
}
165+
166+
if (this.config.embeds.footerText != null && !this.config.embeds.footerText.trim().isEmpty()) {
167+
builder.setFooter(
168+
this.config.embeds.footerText,
169+
UrlValidator.isValid(this.config.embeds.footerIcon) ? this.config.embeds.footerIcon : null);
170+
}
171+
172+
if (this.config.embeds.showTimestamp) {
173+
builder.setTimestamp(Instant.now());
174+
}
175+
176+
return builder.build();
177+
}
178+
179+
private TextChannel createChannel(long userId, TicketConfig.TicketCategoryConfig category, long ticketId) {
180+
Category cat = this.jda.getCategoryById(this.config.categoryId);
181+
if (cat == null) {
182+
throw new IllegalStateException("Category not found: " + this.config.categoryId);
183+
}
184+
185+
User user = this.jda.getUserById(userId);
186+
String userName = user != null ? user.getName() : "user" + userId;
187+
long count = this.ticketService.countTicketsByUser(userId).join();
188+
String channelName = count == 0 ? "ticket-" + userName : "ticket-" + userName + "-" + (count + 1);
189+
190+
TextChannel channel = cat.createTextChannel(channelName)
191+
.setTopic("Ticket " + category.displayName)
192+
.complete();
193+
194+
this.setupPermissions(channel, userId);
195+
return channel;
196+
}
197+
198+
private void setupPermissions(TextChannel channel, long userId) {
199+
Guild guild = channel.getGuild();
200+
201+
channel.getManager()
202+
.putPermissionOverride(guild.getPublicRole(), null, List.of(Permission.VIEW_CHANNEL))
203+
.queue();
204+
205+
Member member = guild.getMemberById(userId);
206+
if (member != null) {
207+
channel.getManager()
208+
.putPermissionOverride(
209+
member,
210+
List.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.MESSAGE_HISTORY),
211+
null)
212+
.queue();
213+
}
214+
215+
if (this.config.staffRoleId != 0L) {
216+
Role staffRole = guild.getRoleById(this.config.staffRoleId);
217+
if (staffRole != null) {
218+
channel.getManager()
219+
.putPermissionOverride(
220+
staffRole,
221+
List.of(
222+
Permission.VIEW_CHANNEL,
223+
Permission.MESSAGE_SEND,
224+
Permission.MESSAGE_HISTORY,
225+
Permission.MESSAGE_MANAGE),
226+
null)
227+
.queue();
228+
}
229+
}
230+
}
231+
}

0 commit comments

Comments
 (0)