Skip to content

Commit a741f6f

Browse files
feat(DynamicVoiceChat): implement main logic (Together-Java#1370)
* feat(DynamicVoiceChat): implement main logic Co-authored-by: Suraj Kumar <[email protected]> Signed-off-by: Chris Sdogkos <[email protected]> * DynamicVoiceChat.java: use trace instead of info Using 'Logger#info' is too spammy in the console, use 'Logger#trace' instead. Signed-off-by: Chris Sdogkos <[email protected]> * DynamicVoiceChat.java: more trace instead of info Signed-off-by: Chris Sdogkos <[email protected]> * Make class final and add JavaDoc Signed-off-by: Chris Sdogkos <[email protected]> * Archive instead of simply deleting voice channels Currently there is no way to moderate ephemeral voice channels. Members could very easily break the rules in the channel, send NSFW content and it can go undetected by the moderation team. Introduce a new archival system where the ephemeral voice channels are instead stored in an archival category. Depending on the archival strategy, channels are removed once they are not needed any more. Routines are not being used since we are able to get away with attempting cleanup every time a user leaves an ephemeral voice channel. This results in superior performance and no scheduling involved. Do _not_ archive ephemeral voice channels with no contents sent by members. Signed-off-by: Chris Sdogkos <[email protected]> * Use proper constant values This is why breaks are important, I committed my previous changes while being hungry... Set realistic constant values for CLEAN_CHANNELS_AMOUNT and MINIMUM_CHANNELS_AMOUNT so that more channels are kept in the archive. Signed-off-by: Chris Sdogkos <[email protected]> * DynamicVoiceChat.java: Remove defensive comment The constant name is self-documenting. If someone wants to make it as well as other constants in this class configurable later, they can submit a pull request with _actual reasoning_ instead of bikeshedding over a defensive comment. The constant is hardcoded because it's _unlikely_ anyone will want to change it. If that assumption proves wrong, it's trivial to refactor. Signed-off-by: Chris Sdogkos <[email protected]> * Make the already existing constants configurable from config.json For the purposes of speeding up the process of reviews and since it really doesn't matter if they are configurable or not (just extra unnecessary development time), move all the constants into the config.json so that users who run the bot can easily configure these constants without touching the code. Signed-off-by: Chris Sdogkos <[email protected]> --------- Signed-off-by: Chris Sdogkos <[email protected]> Co-authored-by: Suraj Kumar <[email protected]>
1 parent c8003e7 commit a741f6f

File tree

7 files changed

+275
-1
lines changed

7 files changed

+275
-1
lines changed

application/config.json.template

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,5 +204,15 @@
204204
"rolePattern": "Top Helper.*",
205205
"assignmentChannelPattern": "community-commands",
206206
"announcementChannelPattern": "hall-of-fame"
207+
},
208+
"dynamicVoiceChatConfig": {
209+
"dynamicChannelPatterns": [
210+
"Gaming",
211+
"Support/Studying Room",
212+
"Chit Chat"
213+
],
214+
"archiveCategoryPattern": "Voice Channel Archives",
215+
"cleanChannelsAmount": 20,
216+
"minimumChannelsAmount": 40
207217
}
208218
}

application/src/main/java/org/togetherjava/tjbot/config/Config.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public final class Config {
5050
private final String memberCountCategoryPattern;
5151
private final QuoteBoardConfig quoteBoardConfig;
5252
private final TopHelpersConfig topHelpers;
53+
private final DynamicVoiceChatConfig dynamicVoiceChatConfig;
5354

5455
@SuppressWarnings("ConstructorWithTooManyParameters")
5556
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
@@ -105,7 +106,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
105106
required = true) String selectRolesChannelPattern,
106107
@JsonProperty(value = "quoteBoardConfig",
107108
required = true) QuoteBoardConfig quoteBoardConfig,
108-
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
109+
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
110+
@JsonProperty(value = "dynamicVoiceChatConfig",
111+
required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) {
109112
this.token = Objects.requireNonNull(token);
110113
this.githubApiKey = Objects.requireNonNull(githubApiKey);
111114
this.databasePath = Objects.requireNonNull(databasePath);
@@ -142,6 +145,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
142145
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
143146
this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig);
144147
this.topHelpers = Objects.requireNonNull(topHelpers);
148+
this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig);
145149
}
146150

147151
/**
@@ -473,4 +477,13 @@ public RSSFeedsConfig getRSSFeedsConfig() {
473477
public TopHelpersConfig getTopHelpers() {
474478
return topHelpers;
475479
}
480+
481+
/**
482+
* Gets the dynamic voice chat configuration
483+
*
484+
* @return the dynamic voice chat configuration
485+
*/
486+
public DynamicVoiceChatConfig getDynamicVoiceChatConfig() {
487+
return dynamicVoiceChatConfig;
488+
}
476489
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.togetherjava.tjbot.config;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
5+
import java.util.List;
6+
import java.util.Objects;
7+
import java.util.regex.Pattern;
8+
9+
/**
10+
* Configuration for the dynamic voice chat feature.
11+
*
12+
* @param archiveCategoryPattern the name of the Discord Guild category in which the archived
13+
* channels will go
14+
* @param cleanChannelsAmount the amount of channels to clean once a cleanup is triggered
15+
* @param minimumChannelsAmount the amount of voice channels for the archive category to have before
16+
* a cleanup triggers
17+
*/
18+
public record DynamicVoiceChatConfig(
19+
@JsonProperty(value = "dynamicChannelPatterns",
20+
required = true) List<Pattern> dynamicChannelPatterns,
21+
@JsonProperty(value = "archiveCategoryPattern",
22+
required = true) String archiveCategoryPattern,
23+
@JsonProperty(value = "cleanChannelsAmount") int cleanChannelsAmount,
24+
@JsonProperty(value = "minimumChannelsAmount", required = true) int minimumChannelsAmount) {
25+
26+
public DynamicVoiceChatConfig {
27+
Objects.requireNonNull(dynamicChannelPatterns);
28+
Objects.requireNonNull(archiveCategoryPattern);
29+
}
30+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener;
8080
import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine;
8181
import org.togetherjava.tjbot.features.tophelper.TopHelpersService;
82+
import org.togetherjava.tjbot.features.voicechat.DynamicVoiceChat;
8283

8384
import java.util.ArrayList;
8485
import java.util.Collection;
@@ -164,6 +165,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
164165
features.add(new PinnedNotificationRemover(config));
165166
features.add(new QuoteBoardForwarder(config));
166167

168+
// Voice receivers
169+
features.add(new DynamicVoiceChat(config));
170+
167171
// Event receivers
168172
features.add(new RejoinModerationRoleListener(actionsStore, config));
169173
features.add(new GuildLeaveCloseThreadListener(config));
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package org.togetherjava.tjbot.features.voicechat;
2+
3+
import net.dv8tion.jda.api.EmbedBuilder;
4+
import net.dv8tion.jda.api.entities.Guild;
5+
import net.dv8tion.jda.api.entities.Member;
6+
import net.dv8tion.jda.api.entities.MessageEmbed;
7+
import net.dv8tion.jda.api.entities.MessageHistory;
8+
import net.dv8tion.jda.api.entities.channel.concrete.Category;
9+
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
10+
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
11+
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
12+
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
13+
import net.dv8tion.jda.api.managers.channel.middleman.AudioChannelManager;
14+
import net.dv8tion.jda.api.requests.RestAction;
15+
import org.jetbrains.annotations.NotNull;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
19+
import org.togetherjava.tjbot.config.Config;
20+
import org.togetherjava.tjbot.config.DynamicVoiceChatConfig;
21+
import org.togetherjava.tjbot.features.VoiceReceiverAdapter;
22+
23+
import java.util.Optional;
24+
25+
/**
26+
* Handles dynamic voice channel creation and deletion based on user activity.
27+
* <p>
28+
* When a member joins a configured root channel, a temporary copy is created and the member is
29+
* moved into it. Once the channel becomes empty, it is archived and further deleted using a
30+
* {@link VoiceChatCleanupStrategy}.
31+
*/
32+
public final class DynamicVoiceChat extends VoiceReceiverAdapter {
33+
private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class);
34+
35+
private final VoiceChatCleanupStrategy voiceChatCleanupStrategy;
36+
private final DynamicVoiceChatConfig dynamicVoiceChannelConfig;
37+
38+
public DynamicVoiceChat(Config config) {
39+
this.dynamicVoiceChannelConfig = config.getDynamicVoiceChatConfig();
40+
41+
this.voiceChatCleanupStrategy =
42+
new OldestVoiceChatCleanup(dynamicVoiceChannelConfig.cleanChannelsAmount(),
43+
dynamicVoiceChannelConfig.minimumChannelsAmount());
44+
}
45+
46+
@Override
47+
public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
48+
AudioChannelUnion channelJoined = event.getChannelJoined();
49+
AudioChannelUnion channelLeft = event.getChannelLeft();
50+
51+
if (channelJoined != null && eventHappenOnDynamicRootChannel(channelJoined)) {
52+
logger.debug("Event happened on joined channel {}", channelJoined);
53+
createDynamicVoiceChannel(event, channelJoined.asVoiceChannel());
54+
}
55+
56+
if (channelLeft != null && !eventHappenOnDynamicRootChannel(channelLeft)) {
57+
logger.debug("Event happened on left channel {}", channelLeft);
58+
59+
MessageHistory messageHistory = channelLeft.asVoiceChannel().getHistory();
60+
messageHistory.retrievePast(2).queue(messages -> {
61+
// Don't forget that there is always one
62+
// embed message sent by the bot every time.
63+
if (messages.size() > 1) {
64+
archiveDynamicVoiceChannel(channelLeft);
65+
} else {
66+
channelLeft.delete().queue();
67+
}
68+
});
69+
}
70+
}
71+
72+
private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) {
73+
return dynamicVoiceChannelConfig.dynamicChannelPatterns()
74+
.stream()
75+
.anyMatch(pattern -> pattern.matcher(channel.getName()).matches());
76+
}
77+
78+
private void createDynamicVoiceChannel(@NotNull GuildVoiceUpdateEvent event,
79+
VoiceChannel channel) {
80+
Guild guild = event.getGuild();
81+
Member member = event.getMember();
82+
String newChannelName = "%s's %s".formatted(member.getEffectiveName(), channel.getName());
83+
84+
channel.createCopy()
85+
.setName(newChannelName)
86+
.setPosition(channel.getPositionRaw())
87+
.onSuccess(newChannel -> {
88+
moveMember(guild, member, newChannel);
89+
sendWarningEmbed(newChannel);
90+
})
91+
.queue(newChannel -> logger.trace("Successfully created {} voice channel.",
92+
newChannel.getName()),
93+
error -> logger.error("Failed to create dynamic voice channel", error));
94+
}
95+
96+
private void moveMember(Guild guild, Member member, AudioChannel channel) {
97+
guild.moveVoiceMember(member, channel)
98+
.queue(_ -> logger.trace(
99+
"Successfully moved {} to newly created dynamic voice channel {}",
100+
member.getEffectiveName(), channel.getName()),
101+
error -> logger.error(
102+
"Failed to move user into dynamically created voice channel {}, {}",
103+
member.getNickname(), channel.getName(), error));
104+
}
105+
106+
private void archiveDynamicVoiceChannel(AudioChannelUnion channel) {
107+
int memberCount = channel.getMembers().size();
108+
String channelName = channel.getName();
109+
110+
if (memberCount > 0) {
111+
logger.debug("Voice channel {} not empty ({} members), so not removing.", channelName,
112+
memberCount);
113+
return;
114+
}
115+
116+
Optional<Category> archiveCategoryOptional = channel.getGuild()
117+
.getCategoryCache()
118+
.stream()
119+
.filter(c -> c.getName()
120+
.equalsIgnoreCase(dynamicVoiceChannelConfig.archiveCategoryPattern()))
121+
.findFirst();
122+
123+
AudioChannelManager<?, ?> channelManager = channel.getManager();
124+
RestAction<Void> restActionChain =
125+
channelManager.setName(String.format("%s (Archived)", channelName))
126+
.and(channel.getPermissionContainer().getManager().clearOverridesAdded());
127+
128+
if (archiveCategoryOptional.isEmpty()) {
129+
logger.warn("Could not find archive category. Attempting to create one...");
130+
channel.getGuild()
131+
.createCategory(dynamicVoiceChannelConfig.archiveCategoryPattern())
132+
.queue(newCategory -> restActionChain.and(channelManager.setParent(newCategory))
133+
.queue());
134+
return;
135+
}
136+
137+
archiveCategoryOptional.ifPresent(archiveCategory -> restActionChain
138+
.and(channelManager.setParent(archiveCategory))
139+
.queue(_ -> voiceChatCleanupStrategy.cleanup(archiveCategory.getVoiceChannels()),
140+
err -> logger.error("Could not archive dynamic voice chat", err)));
141+
}
142+
143+
private void sendWarningEmbed(VoiceChannel channel) {
144+
MessageEmbed messageEmbed = new EmbedBuilder()
145+
.addField("👋 Heads up!",
146+
"""
147+
This is a **temporary** voice chat channel. Messages sent here will be *cleared* once \
148+
the channel is deleted when everyone leaves. If you need to keep something important, \
149+
make sure to save it elsewhere. 💬
150+
""",
151+
false)
152+
.build();
153+
154+
channel.sendMessageEmbeds(messageEmbed).queue();
155+
}
156+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.togetherjava.tjbot.features.voicechat;
2+
3+
import net.dv8tion.jda.api.entities.ISnowflake;
4+
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
5+
6+
import java.util.Comparator;
7+
import java.util.List;
8+
9+
/**
10+
* Cleans up voice chats from an archive prioritizing the oldest {@link VoiceChannel}.
11+
* <p>
12+
* Considering a list of voice channels is provided with all of them obviously having a different
13+
* addition time, the first few {@link VoiceChannel} elements provided, amounting to the value of
14+
* <code>cleanChannelsAmount</code> will be removed from the guild.
15+
* <p>
16+
* The cleanup strategy will <i>not</i> be executed if the amount of voice channels does not exceed
17+
* the value of <code>minimumChannelsAmountToTrigger</code>.
18+
*/
19+
final class OldestVoiceChatCleanup implements VoiceChatCleanupStrategy {
20+
21+
private final int cleanChannelsAmount;
22+
private final int minimumChannelsAmountToTrigger;
23+
24+
OldestVoiceChatCleanup(int cleanChannelsAmount, int minimumChannelsAmountToTrigger) {
25+
this.cleanChannelsAmount = cleanChannelsAmount;
26+
this.minimumChannelsAmountToTrigger = minimumChannelsAmountToTrigger;
27+
}
28+
29+
@Override
30+
public void cleanup(List<VoiceChannel> voiceChannels) {
31+
if (voiceChannels.size() < minimumChannelsAmountToTrigger) {
32+
return;
33+
}
34+
35+
voiceChannels.stream()
36+
.sorted(Comparator.comparing(ISnowflake::getTimeCreated))
37+
.limit(cleanChannelsAmount)
38+
.forEach(voiceChannel -> voiceChannel.delete().queue());
39+
}
40+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.togetherjava.tjbot.features.voicechat;
2+
3+
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
4+
5+
import java.util.List;
6+
7+
/**
8+
* Voice chat cleanup strategy interface for handling voice chat archive removal.
9+
* <p>
10+
* See provided implementation {@link OldestVoiceChatCleanup} for a more concrete usage example.
11+
*/
12+
public interface VoiceChatCleanupStrategy {
13+
14+
/**
15+
* Attempts to delete the {@link VoiceChannel} channels from the Discord guild found in the
16+
* inputted list.
17+
*
18+
* @param voiceChannels a list of voice channels to be considered for removal
19+
*/
20+
void cleanup(List<VoiceChannel> voiceChannels);
21+
}

0 commit comments

Comments
 (0)