Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion application/config.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -199,5 +199,10 @@
"rolePattern": "Top Helper.*",
"assignmentChannelPattern": "community-commands",
"announcementChannelPattern": "hall-of-fame"
}
},
"dynamicVoiceChannelPatterns": [
"Gaming",
"Support/Studying Room",
"Chit Chat"
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public final class Config {
private final String selectRolesChannelPattern;
private final String memberCountCategoryPattern;
private final TopHelpersConfig topHelpers;
private final List<String> dynamicVoiceChannelPatterns;

@SuppressWarnings("ConstructorWithTooManyParameters")
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Expand Down Expand Up @@ -102,7 +103,9 @@ private Config(@JsonProperty(value = "token", required = true) String token,
@JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig,
@JsonProperty(value = "selectRolesChannelPattern",
required = true) String selectRolesChannelPattern,
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) {
@JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers,
@JsonProperty(value = "dynamicVoiceChannelPatterns",
required = true) List<String> dynamicVoiceChannelPatterns) {
this.token = Objects.requireNonNull(token);
this.githubApiKey = Objects.requireNonNull(githubApiKey);
this.databasePath = Objects.requireNonNull(databasePath);
Expand Down Expand Up @@ -138,6 +141,7 @@ private Config(@JsonProperty(value = "token", required = true) String token,
this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig);
this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern);
this.topHelpers = Objects.requireNonNull(topHelpers);
this.dynamicVoiceChannelPatterns = Objects.requireNonNull(dynamicVoiceChannelPatterns);
}

/**
Expand Down Expand Up @@ -457,4 +461,13 @@ public RSSFeedsConfig getRSSFeedsConfig() {
public TopHelpersConfig getTopHelpers() {
return topHelpers;
}

/**
* Gets the list of voice channel patterns that are treated dynamically.
*
* @return the list of dynamic voice channel patterns
*/
public List<String> getDynamicVoiceChannelPatterns() {
return dynamicVoiceChannelPatterns;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
import org.togetherjava.tjbot.features.tophelper.TopHelpersMessageListener;
import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine;
import org.togetherjava.tjbot.features.tophelper.TopHelpersService;
import org.togetherjava.tjbot.features.voicechat.DynamicVoiceChat;

import java.util.ArrayList;
import java.util.Collection;
Expand Down Expand Up @@ -161,6 +162,9 @@ public static Collection<Feature> createFeatures(JDA jda, Database database, Con
features.add(new SlashCommandEducator());
features.add(new PinnedNotificationRemover(config));

// Voice receivers
features.add(new DynamicVoiceChat(config));

// Event receivers
features.add(new RejoinModerationRoleListener(actionsStore, config));
features.add(new GuildLeaveCloseThreadListener(config));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.togetherjava.tjbot.features.voicechat;

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion;
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.togetherjava.tjbot.config.Config;
import org.togetherjava.tjbot.features.VoiceReceiverAdapter;

import java.util.List;
import java.util.regex.Pattern;

/**
* Handles dynamic voice channel creation and deletion based on user activity.
* <p>
* When a member joins a configured root channel, a temporary copy is created and the member is
* moved into it. Once the channel becomes empty, it is deleted.
*/
public final class DynamicVoiceChat extends VoiceReceiverAdapter {
private static final Logger logger = LoggerFactory.getLogger(DynamicVoiceChat.class);
private final List<Pattern> dynamicVoiceChannelPatterns;

public DynamicVoiceChat(Config config) {
this.dynamicVoiceChannelPatterns =
config.getDynamicVoiceChannelPatterns().stream().map(Pattern::compile).toList();
}

@Override
public void onVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) {
AudioChannelUnion channelJoined = event.getChannelJoined();
AudioChannelUnion channelLeft = event.getChannelLeft();

if (channelJoined != null && eventHappenOnDynamicRootChannel(channelJoined)) {
logger.debug("Event happened on joined channel {}", channelJoined);
createDynamicVoiceChannel(event, channelJoined.asVoiceChannel());
}

if (channelLeft != null && !eventHappenOnDynamicRootChannel(channelLeft)) {
logger.debug("Event happened on left channel {}", channelLeft);
deleteDynamicVoiceChannel(channelLeft);
}
}

private boolean eventHappenOnDynamicRootChannel(AudioChannelUnion channel) {
return dynamicVoiceChannelPatterns.stream()
.anyMatch(pattern -> pattern.matcher(channel.getName()).matches());
}

private void createDynamicVoiceChannel(@NotNull GuildVoiceUpdateEvent event,
VoiceChannel channel) {
Guild guild = event.getGuild();
Member member = event.getMember();
String newChannelName = "%s's %s".formatted(member.getEffectiveName(), channel.getName());

channel.createCopy()
.setName(newChannelName)
.setPosition(channel.getPositionRaw())
.onSuccess(newChannel -> {
moveMember(guild, member, newChannel);
sendWarningEmbed(newChannel);
})
.queue(newChannel -> logger.trace("Successfully created {} voice channel.",
newChannel.getName()),
error -> logger.error("Failed to create dynamic voice channel", error));
}

private void moveMember(Guild guild, Member member, AudioChannel channel) {
guild.moveVoiceMember(member, channel)
.queue(_ -> logger.trace(
"Successfully moved {} to newly created dynamic voice channel {}",
member.getEffectiveName(), channel.getName()),
error -> logger.error(
"Failed to move user into dynamically created voice channel {}, {}",
member.getNickname(), channel.getName(), error));
}

private void deleteDynamicVoiceChannel(AudioChannelUnion channel) {
int memberCount = channel.getMembers().size();

if (memberCount > 0) {
logger.debug("Voice channel {} not empty ({} members), so not removing.",
channel.getName(), memberCount);
return;
}

channel.delete()
.queue(_ -> logger.trace("Deleted dynamically created voice channel: {} ",
channel.getName()),
error -> logger.error("Failed to delete dynamically created voice channel: {} ",
channel.getName(), error));
}
Comment on lines +94 to +99
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I would kinda prefer if this workflow will post a warning in the voice channel chat with a specific timer for deletion (30sec) or whatever, then checking again for memberCount == 0 and then actually deleting or otherwise stopping the workflow

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this when reviewing and decided to skip past a timer. There is a message sent when the channel is created and there's really no need to keep the channel longer. It can be recreated right away anyway.

Having said this, I am now thinking about moderation...

Temp voice chat -> users posting nonsense -> evidence deleted ...?

Perhaps instead of deleting the channel, we can move it to a private/mod-only voice-chat-archive channel so only we can still check chats. Followed by a daily clean up task.

What do you think?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That sounds great, we need to keep track of what people post in these ephemeral channels. Perhaps instead of deleting, send a log of all the messages sent as one embed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You'll hit the character limit very quickly with an embed. It's best to just hide the channel so only moderators can see this and clean up after some time period.


private void sendWarningEmbed(VoiceChannel channel) {
MessageEmbed messageEmbed = new EmbedBuilder()
.addField("👋 Heads up!",
"""
This is a **temporary** voice chat channel. Messages sent here will be *cleared* once \
the channel is deleted when everyone leaves. If you need to keep something important, \
make sure to save it elsewhere. 💬
""",
false)
.build();

channel.sendMessageEmbeds(messageEmbed).queue();
}
}
Loading