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
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class ModerationConfig extends GuildConfigItem {
private long staffRoleId = 0;
private long adminRoleId = 0;
private long expertRoleId = 0;
private long colorRoleChangeRoleId = 0;

/**
* The time window, in seconds, that the cross-channel spam automod looks back over when
Expand Down Expand Up @@ -180,6 +181,10 @@ public Role getAdminRole() {
public Role getExpertRole() {
return this.getGuild().getRoleById(this.expertRoleId);
}

public Role getColorRoleChangeRole() {
return this.getGuild().getRoleById(this.colorRoleChangeRoleId);
}

public TextChannel getNotificationThreadChannel() {
return this.getGuild().getTextChannelById(this.notificationThreadChannelId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package net.discordjug.javabot.systems.color_roles;

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

import net.discordjug.javabot.data.config.BotConfig;
import net.discordjug.javabot.util.Responses;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.AutoCompleteQuery;
import net.dv8tion.jda.api.interactions.commands.Command.Choice;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import xyz.dynxsty.dih4jda.interactions.AutoCompletable;
import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand;
import xyz.dynxsty.dih4jda.util.AutoCompleteUtils;

/**
* The {@code /color} command which allows users to change their own color with color roles.
*/
public class ChangeColorCommand extends SlashCommand implements AutoCompletable {

private static final Pattern COLOR_PATTERN = Pattern.compile("#[A-F0-9]{6}", Pattern.CASE_INSENSITIVE);

private final BotConfig config;
private final ColorRoleService service;

/**
* The constructor which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}.
* @param botConfig The bot's configuration
* @param service The {@link ColorRoleService} to actually change color roles
*/
public ChangeColorCommand(BotConfig botConfig, ColorRoleService service) {
this.config = botConfig;
this.service = service;
setCommandData(Commands.slash("color", "Sets the color of your roles")
.addOption(OptionType.STRING, "color", "The hex code of the role color (leave empty to remove your color roles)", false, true));
}

@Override
public void execute(SlashCommandInteractionEvent event) {
Role baseRole = config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole();
if (baseRole == null) {
Responses.error(event, "This feature is not available.").queue();
return;
}
if (!event.getMember().getRoles().contains(baseRole)) {
Responses.error(event, "Only users with the " + baseRole.getAsMention() + " role can change their color.")
.setAllowedMentions(List.of())
.queue();
return;
}

String colorArgument = event.getOption("color", "", OptionMapping::getAsString);

Color color;
if (colorArgument.isEmpty()) {
color = null;
} else {
if (!colorArgument.startsWith("#")) {
colorArgument = "#" + colorArgument;
}
if (!COLOR_PATTERN.matcher(colorArgument).matches()) {
Responses.error(event, "'%s' is not a valid color", colorArgument).queue();
return;
}
try {
color = Color.decode(colorArgument);
} catch (NumberFormatException e) {
Responses.error(event, "'%s' is not a valid color", colorArgument).queue();
return;
}
}

boolean removed = service.removeColor(event.getMember());
if (color == null) {
if (removed) {
Responses.success(event, "Color removed", "Your color role has been removed.").queue();
} else {
Responses.error(event, "You don't have a color role.").queue();
}
return;
}
service.addColor(event.getMember(), color);
Responses.success(event, "Color set", "Your color has been set to `%s`.", service.encodeColor(color)).queue();
}

@Override
public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) {
Role baseRole = config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole();
if (baseRole == null) {
event.replyChoice("Feature unavailable", "This feature is not available.").queue();
return;
}
if (!event.getMember().getRoles().contains(baseRole)) {
event.replyChoice("Not permitted", "You cannot use this feature.").queue();
return;
}
List<Choice> choices = service.getColorRoles(event.getGuild())
.stream()
.map(rc -> new Choice(service.encodeColor(rc.color()), service.encodeColor(rc.color())))
.toList();
event.replyChoices(AutoCompleteUtils.filterChoices(event, choices)).queue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.discordjug.javabot.systems.color_roles;

import java.awt.Color;
import java.util.Objects;

/**
* A self-assignable color role.
* @param guildId The Discord ID of the guild the role is a part of
* @param roleId The Discord ID of the role
* @param color The color of the role
* @see ChangeColorCommand
* @see ColorRoleService
*/
public record ColorRole(long guildId, long roleId, Color color) {
public ColorRole {
Objects.requireNonNull(color);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package net.discordjug.javabot.systems.color_roles;

import java.awt.Color;
import java.util.List;

import net.discordjug.javabot.data.config.BotConfig;
import net.discordjug.javabot.systems.color_roles.dao.ColorRoleRepository;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent;
import net.dv8tion.jda.api.events.guild.member.GuildMemberUpdateEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.springframework.stereotype.Service;

/**
* Service class for assigning and removing color roles.
* @see ColorRoleService#addColor(Member, Color)
* @see ColorRoleService#removeColor(Member)
*/
@Service
public class ColorRoleService extends ListenerAdapter {

private final ColorRoleRepository repo;
private final BotConfig config;

public ColorRoleService(ColorRoleRepository repo, BotConfig config) {
this.repo = repo;
this.config = config;
}

/**
* Adds a color role to a user.
*
* If a color role with the given color exists already, it will be reused. Otherwise, it will create a new one.
* @param member The member to add the role to
* @param color The color of the role to assign
*/
public synchronized void addColor(Member member, Color color) {
repo.getFromColor(member.getGuild().getIdLong(), color)
.ifPresentOrElse(colorRole -> {
member.getGuild().addRoleToMember(member, member.getGuild().getRoleById(colorRole.roleId())).queue();
repo.addRoleToMember(colorRole.roleId(), member.getIdLong());
}, () -> {
member.getGuild().createRole()
.setName(encodeColor(color))
.setColor(color)
.queue(role -> {
member.getGuild().addRoleToMember(member, role).queue();
repo.addColorRole(new ColorRole(member.getGuild().getIdLong(), role.getIdLong(), color), member.getIdLong());
member.getGuild().modifyRolePositions()
.selectPosition(role)
.moveAbove(config.get(member.getGuild()).getModerationConfig().getColorRoleChangeRole())
.queue();
});
});
}

/**
* Removes all color roles from a user.
*
* If no other members have that role, the role will be deleted.
* @param member The member to remove the role from
* @return {@code true} if any color roles were removed, else {@code false}
*/
public synchronized boolean removeColor(Member member) {
boolean changed = false;
for (ColorRole color : repo.getColorRolesOfMember(member.getGuild().getIdLong(), member.getIdLong())) {
repo.removeRoleFromMember(color.roleId(), member.getIdLong());
Role role = member.getGuild().getRoleById(color.roleId());
if (repo.cleanRoleIfNoMembers(color.roleId())) {
role.delete().queue();
} else {
member.getGuild().removeRoleFromMember(member, role).queue();
}
changed = true;
}
return changed;
}

@Override
public void onGuildMemberUpdate(GuildMemberUpdateEvent event) {
// Remove color roles if a member if they lost the relevant role
// since members are not cached, we cannot use specific event types
if (!event.getMember().getUnsortedRoles().contains(config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole())) {
removeColor(event.getMember());
}
}

@Override
public synchronized void onGuildMemberRemove(GuildMemberRemoveEvent event) {
for (ColorRole color : repo.getColorRolesOfMember(event.getGuild().getIdLong(), event.getUser().getIdLong())) {
repo.removeRoleFromMember(color.roleId(), event.getUser().getIdLong());
Role role = event.getGuild().getRoleById(color.roleId());
if (repo.cleanRoleIfNoMembers(color.roleId())) {
role.delete().queue();
}
}
}

public List<ColorRole> getColorRoles(Guild guild) {
return repo.getColorRoles(guild.getIdLong());
}

public String encodeColor(Color color) {
return String.format("#%2h%2h%2h", color.getRed(), color.getGreen(), color.getBlue()).replace(' ', '0');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package net.discordjug.javabot.systems.color_roles.dao;

import java.awt.Color;
import java.util.List;
import java.util.Optional;

import javax.annotation.CheckReturnValue;

import lombok.RequiredArgsConstructor;
import net.discordjug.javabot.systems.color_roles.ColorRole;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

/**
* Repository class for the COLOR_ROLES and COLOR_ROLE_MEMBERS tables which are used for role colors.
*/
@Repository
@RequiredArgsConstructor
public class ColorRoleRepository {
private final JdbcTemplate jdbcTemplate;

/**
* Gets all color roles of a guild.
* @param guildId The ID of the guild
* @return A {@link List} of all registered role colors in the guild
*/
public List<ColorRole> getColorRoles(long guildId) {
return jdbcTemplate.query("SELECT ROLE_ID, COLOR FROM COLOR_ROLES WHERE GUILD_ID=?",
(rs, _) -> new ColorRole(guildId, rs.getLong(1), new Color(rs.getInt(2))),
guildId);
}

/**
* Tries to find a color role given the color.
* @param guildId The ID of the guild containing the role
* @param color The color of the role to search for
* @return The color role if it exists or {@link Optional#empty()}
*/
public Optional<ColorRole> getFromColor(long guildId, Color color) {
try {
ColorRole result = jdbcTemplate.queryForObject("SELECT ROLE_ID FROM COLOR_ROLES WHERE GUILD_ID=? AND COLOR=?",
(rs, _) -> new ColorRole(guildId, rs.getLong(1), color),
guildId, color.getRGB());
return Optional.ofNullable(result);
} catch(EmptyResultDataAccessException _) {
return Optional.empty();
}
}

/**
* Gets all color roles of a given member.
* @param guildId The ID of the guild the roles should be part of
* @param memberId The ID of the member get the roles of
* @return All color roles of the given member in the given guild
*/
public List<ColorRole> getColorRolesOfMember(long guildId, long memberId) {
return jdbcTemplate.query("""
SELECT cr.ROLE_ID, cr.COLOR
FROM COLOR_ROLES cr
INNER JOIN COLOR_ROLE_MEMBERS crm ON cr.ROLE_ID = crm.ROLE_ID
WHERE cr.GUILD_ID=? AND crm.USER_ID=?
""",
(rs, _) -> new ColorRole(guildId, rs.getLong(1), new Color(rs.getInt(2))),
guildId, memberId);
}

/**
* Registers that a member has a given color role.
* @param color The color role
* @param memberId The member the role is being added to
*/
public void addColorRole(ColorRole color, long memberId) {
jdbcTemplate.update("INSERT INTO COLOR_ROLES (ROLE_ID, GUILD_ID, COLOR) VALUES (?,?,?)",
color.roleId(), color.guildId(), color.color().getRGB());
addRoleToMember(color.roleId(), memberId);
}

/**
* Registers that a color role has been added to a member.
* @param roleId The ID of the role the member has
* @param memberId The ID of the member to update
*/
public void addRoleToMember(long roleId, long memberId) {
jdbcTemplate.update("INSERT INTO COLOR_ROLE_MEMBERS (ROLE_ID, USER_ID) VALUES (?,?)", roleId, memberId);
}

/**
* Registers that a color role has been added to a member.
* @param roleId The ID of the role removed from the member
* @param memberId The ID of the member to update
* @see #cleanRoleIfNoMembers(long)
*/
public void removeRoleFromMember(long roleId, long memberId) {
jdbcTemplate.update("DELETE FROM COLOR_ROLE_MEMBERS WHERE ROLE_ID=? AND USER_ID=?", roleId, memberId);
}

/**
* Removes a role from the database if it has no members.
*
* Callers should make sure to actually delete the role if this method returns {@code true}.
* @param roleId The ID of the role to possibly remove
* @return {@code true} if the role is registered as removed due to not having any members, else {@code false}
*/
@CheckReturnValue
public boolean cleanRoleIfNoMembers(long roleId) {
return jdbcTemplate.update("DELETE FROM COLOR_ROLES WHERE ROLE_ID=? AND (SELECT COUNT(*) FROM COLOR_ROLE_MEMBERS WHERE ROLE_ID=?)=0", roleId, roleId) > 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE COLOR_ROLES (
ROLE_ID BIGINT NOT NULL PRIMARY KEY,
GUILD_ID BIGINT NOT NULL,
COLOR INTEGER NOT NULL UNIQUE
);
CREATE TABLE COLOR_ROLE_MEMBERS (
ROLE_ID BIGINT NOT NULL,
USER_ID BIGINT NOT NULL,
PRIMARY KEY(ROLE_ID, USER_ID),
FOREIGN KEY(ROLE_ID) REFERENCES COLOR_ROLES(ROLE_ID)
);
12 changes: 12 additions & 0 deletions src/main/resources/database/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -165,3 +165,15 @@ CREATE TABLE form_submissions (
);

CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id);

CREATE TABLE COLOR_ROLES (
ROLE_ID BIGINT NOT NULL PRIMARY KEY,
GUILD_ID BIGINT NOT NULL,
COLOR INTEGER NOT NULL UNIQUE
);
CREATE TABLE COLOR_ROLE_MEMBERS (
ROLE_ID BIGINT NOT NULL,
USER_ID BIGINT NOT NULL,
PRIMARY KEY(ROLE_ID, USER_ID),
FOREIGN KEY(ROLE_ID) REFERENCES COLOR_ROLES(ROLE_ID)
);
Loading