Skip to content

Commit 4bca50e

Browse files
committed
feat(dynamic-vcs): add DynamicVoiceListener code
1 parent a1039f9 commit 4bca50e

File tree

2 files changed

+266
-0
lines changed

2 files changed

+266
-0
lines changed

application/src/main/java/org/togetherjava/tjbot/features/Features.java

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection;
2121
import org.togetherjava.tjbot.features.code.CodeMessageHandler;
2222
import org.togetherjava.tjbot.features.code.CodeMessageManualDetection;
23+
import org.togetherjava.tjbot.features.dynamicvc.DynamicVoiceListener;
2324
import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener;
2425
import org.togetherjava.tjbot.features.github.GitHubCommand;
2526
import org.togetherjava.tjbot.features.github.GitHubReference;
@@ -151,6 +152,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
151152
features.add(new SlashCommandEducator());
152153
features.add(new PinnedNotificationRemover(config));
153154

155+
// Voice receivers
156+
features.add(new DynamicVoiceListener(config));
157+
154158
// Event receivers
155159
features.add(new RejoinModerationRoleListener(actionsStore, config));
156160
features.add(new GuildLeaveCloseThreadListener(config));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package org.togetherjava.tjbot.features.dynamicvc;
2+
3+
import net.dv8tion.jda.api.entities.Guild;
4+
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
5+
import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel;
6+
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
7+
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
8+
import org.apache.commons.lang3.tuple.Pair;
9+
import org.jetbrains.annotations.NotNull;
10+
import org.slf4j.Logger;
11+
import org.slf4j.LoggerFactory;
12+
13+
import org.togetherjava.tjbot.config.Config;
14+
import org.togetherjava.tjbot.features.VoiceReceiverAdapter;
15+
16+
import java.util.ArrayList;
17+
import java.util.HashMap;
18+
import java.util.LinkedList;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.Optional;
22+
import java.util.Queue;
23+
import java.util.concurrent.CompletableFuture;
24+
import java.util.concurrent.Executor;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.concurrent.atomic.AtomicBoolean;
27+
import java.util.function.Predicate;
28+
import java.util.regex.Matcher;
29+
import java.util.regex.Pattern;
30+
import java.util.stream.IntStream;
31+
import java.util.stream.Stream;
32+
33+
/**
34+
* {@link DynamicVoiceListener} is a feature that dynamically manages voice channels within a
35+
* Discord guild based on user activity.
36+
* <p>
37+
* It is designed to handle events related to voice channel updates (e.g. when users join or leave
38+
* voice channels). It dynamically creates or deletes voice channels to ensure there is always
39+
* <i>one</i> available empty channel for users to join, and removes duplicate empty channels to
40+
* avoid clutter.
41+
* <p>
42+
* This feature relies on configurations provided at initialization to determine the patterns for
43+
* channel names it should manage. The configuration is expected to provide a list of regular
44+
* expression patterns for these channel names.
45+
*/
46+
public class DynamicVoiceListener extends VoiceReceiverAdapter {
47+
48+
private final Logger logger = LoggerFactory.getLogger(DynamicVoiceListener.class);
49+
50+
private final Map<String, Predicate<String>> channelPredicates = new HashMap<>();
51+
private static final Pattern channelTopicPattern = Pattern.compile("(\\s+\\d+)$");
52+
53+
/** Map of event queues for each channel topic. */
54+
private final Map<String, Queue<GuildVoiceUpdateEvent>> eventQueues = new HashMap<>();
55+
56+
/** Map to track if an event queue is currently being processed for each channel topic. */
57+
private final Map<String, AtomicBoolean> activeQueuesMap = new HashMap<>();
58+
59+
/** Boolean to track if events from all queues should be handled at a slower rate. */
60+
private final AtomicBoolean slowmode = new AtomicBoolean(false);
61+
private final Executor eventQueueExecutor =
62+
CompletableFuture.delayedExecutor(1L, TimeUnit.SECONDS);
63+
private static final int SLOWMODE_THRESHOLD = 5;
64+
65+
/**
66+
* Initializes a new {@link DynamicVoiceListener} with the specified configuration.
67+
*
68+
* @param config the configuration containing dynamic voice channel patterns
69+
*/
70+
public DynamicVoiceListener(Config config) {
71+
config.getDynamicVoiceChannelPatterns().forEach(pattern -> {
72+
channelPredicates.put(pattern, Pattern.compile(pattern).asMatchPredicate());
73+
activeQueuesMap.put(pattern, new AtomicBoolean(false));
74+
eventQueues.put(pattern, new LinkedList<>());
75+
});
76+
}
77+
78+
@Override
79+
public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
80+
AudioChannelUnion joinChannel = event.getChannelJoined();
81+
AudioChannelUnion leftChannel = event.getChannelLeft();
82+
83+
if (joinChannel != null) {
84+
insertEventToQueue(event, getChannelTopic(joinChannel.getName()));
85+
}
86+
87+
if (leftChannel != null) {
88+
insertEventToQueue(event, getChannelTopic(leftChannel.getName()));
89+
}
90+
}
91+
92+
private void insertEventToQueue(GuildVoiceUpdateEvent event, String channelTopic) {
93+
var eventQueue = eventQueues.get(channelTopic);
94+
95+
if (eventQueue == null) {
96+
return;
97+
}
98+
99+
eventQueue.add(event);
100+
slowmode.set(eventQueue.size() >= SLOWMODE_THRESHOLD);
101+
102+
if (activeQueuesMap.get(channelTopic).get()) {
103+
return;
104+
}
105+
106+
if (slowmode.get()) {
107+
logger.info("Running with slowmode");
108+
CompletableFuture.runAsync(() -> processEventFromQueue(channelTopic),
109+
eventQueueExecutor);
110+
return;
111+
}
112+
113+
processEventFromQueue(channelTopic);
114+
}
115+
116+
private void processEventFromQueue(String channelTopic) {
117+
AtomicBoolean activeQueueFlag = activeQueuesMap.get(channelTopic);
118+
GuildVoiceUpdateEvent event = eventQueues.get(channelTopic).poll();
119+
120+
if (event == null) {
121+
activeQueueFlag.set(false);
122+
return;
123+
}
124+
125+
activeQueueFlag.set(true);
126+
127+
handleTopicUpdate(event, channelTopic);
128+
}
129+
130+
private void handleTopicUpdate(GuildVoiceUpdateEvent event, String channelTopic) {
131+
AtomicBoolean activeQueueFlag = activeQueuesMap.get(channelTopic);
132+
Guild guild = event.getGuild();
133+
List<CompletableFuture<?>> restActionTasks = new ArrayList<>();
134+
135+
if (channelPredicates.get(channelTopic) == null) {
136+
activeQueueFlag.set(false);
137+
return;
138+
}
139+
140+
long emptyChannelsCount = getEmptyChannelsCountFromTopic(guild, channelTopic);
141+
142+
if (emptyChannelsCount == 0) {
143+
long channelCount = getChannelCountFromTopic(guild, channelTopic);
144+
145+
restActionTasks
146+
.add(makeCreateVoiceChannelFromTopicFuture(guild, channelTopic, channelCount));
147+
} else if (emptyChannelsCount != 1) {
148+
restActionTasks.addAll(makeRemoveDuplicateEmptyChannelsFutures(guild, channelTopic));
149+
restActionTasks.addAll(makeRenameTopicChannelsFutures(guild, channelTopic));
150+
}
151+
152+
if (!restActionTasks.isEmpty()) {
153+
CompletableFuture.allOf(restActionTasks.toArray(CompletableFuture[]::new))
154+
.thenCompose(v -> {
155+
List<CompletableFuture<?>> renameTasks =
156+
makeRenameTopicChannelsFutures(guild, channelTopic);
157+
return CompletableFuture.allOf(renameTasks.toArray(CompletableFuture[]::new));
158+
})
159+
.handle((result, exception) -> {
160+
processEventFromQueue(channelTopic);
161+
activeQueueFlag.set(false);
162+
return null;
163+
});
164+
return;
165+
}
166+
167+
processEventFromQueue(channelTopic);
168+
activeQueueFlag.set(false);
169+
}
170+
171+
private static CompletableFuture<? extends StandardGuildChannel> makeCreateVoiceChannelFromTopicFuture(
172+
Guild guild, String channelTopic, long topicChannelsCount) {
173+
Optional<VoiceChannel> originalTopicChannelOptional =
174+
getOriginalTopicChannel(guild, channelTopic);
175+
176+
if (originalTopicChannelOptional.isPresent()) {
177+
VoiceChannel originalTopicChannel = originalTopicChannelOptional.orElseThrow();
178+
179+
return originalTopicChannel.createCopy()
180+
.setName(getNumberedChannelTopic(channelTopic, topicChannelsCount + 1))
181+
.setPosition(originalTopicChannel.getPositionRaw())
182+
.submit();
183+
}
184+
185+
return CompletableFuture.completedFuture(null);
186+
}
187+
188+
private static Optional<VoiceChannel> getOriginalTopicChannel(Guild guild,
189+
String channelTopic) {
190+
return guild.getVoiceChannels()
191+
.stream()
192+
.filter(channel -> channel.getName().equals(channelTopic))
193+
.findFirst();
194+
}
195+
196+
private List<CompletableFuture<Void>> makeRemoveDuplicateEmptyChannelsFutures(Guild guild,
197+
String channelTopic) {
198+
List<VoiceChannel> channelsToRemove = getVoiceChannelsFromTopic(guild, channelTopic)
199+
.filter(channel -> channel.getMembers().isEmpty())
200+
.toList();
201+
final List<CompletableFuture<Void>> restActionTasks = new ArrayList<>();
202+
203+
channelsToRemove.subList(1, channelsToRemove.size())
204+
.forEach(channel -> restActionTasks.add(channel.delete().submit()));
205+
206+
return restActionTasks;
207+
}
208+
209+
private List<CompletableFuture<?>> makeRenameTopicChannelsFutures(Guild guild,
210+
String channelTopic) {
211+
List<VoiceChannel> topicChannels = getVoiceChannelsFromTopic(guild, channelTopic).toList();
212+
List<CompletableFuture<?>> restActionTasks = new ArrayList<>();
213+
214+
IntStream.range(0, topicChannels.size())
215+
.asLongStream()
216+
.mapToObj(channelId -> Pair.of(channelId + 1, topicChannels.get((int) channelId)))
217+
.filter(pair -> pair.getLeft() != 1)
218+
.forEach(pair -> {
219+
long channelId = pair.getLeft();
220+
VoiceChannel voiceChannel = pair.getRight();
221+
String voiceChannelNameTopic = getChannelTopic(voiceChannel.getName());
222+
223+
restActionTasks.add(voiceChannel.getManager()
224+
.setName(getNumberedChannelTopic(voiceChannelNameTopic, channelId))
225+
.submit());
226+
});
227+
228+
return restActionTasks;
229+
}
230+
231+
private long getChannelCountFromTopic(Guild guild, String channelTopic) {
232+
return getVoiceChannelsFromTopic(guild, channelTopic).count();
233+
}
234+
235+
private Stream<VoiceChannel> getVoiceChannelsFromTopic(Guild guild, String channelTopic) {
236+
return guild.getVoiceChannels()
237+
.stream()
238+
.filter(channel -> channelPredicates.get(channelTopic)
239+
.test(getChannelTopic(channel.getName())));
240+
}
241+
242+
private long getEmptyChannelsCountFromTopic(Guild guild, String channelTopic) {
243+
return getVoiceChannelsFromTopic(guild, channelTopic)
244+
.map(channel -> channel.getMembers().size())
245+
.filter(number -> number == 0)
246+
.count();
247+
}
248+
249+
private static String getChannelTopic(String channelName) {
250+
Matcher channelTopicPatternMatcher = channelTopicPattern.matcher(channelName);
251+
252+
if (channelTopicPatternMatcher.find()) {
253+
return channelTopicPatternMatcher.replaceAll("");
254+
}
255+
256+
return channelName;
257+
}
258+
259+
private static String getNumberedChannelTopic(String channelTopic, long channelId) {
260+
return String.format("%s %d", channelTopic, channelId);
261+
}
262+
}

0 commit comments

Comments
 (0)