Skip to content

Commit 8ff30ec

Browse files
authored
Merge pull request #569 from danthe1st/color-roles
Allow self-assigning role colors
2 parents 57d8dff + ed2ce98 commit 8ff30ec

7 files changed

Lines changed: 371 additions & 0 deletions

File tree

‎src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public class ModerationConfig extends GuildConfigItem {
2727
private long staffRoleId = 0;
2828
private long adminRoleId = 0;
2929
private long expertRoleId = 0;
30+
private long colorRoleChangeRoleId = 0;
3031

3132
/**
3233
* The time window, in seconds, that the cross-channel spam automod looks back over when
@@ -180,6 +181,10 @@ public Role getAdminRole() {
180181
public Role getExpertRole() {
181182
return this.getGuild().getRoleById(this.expertRoleId);
182183
}
184+
185+
public Role getColorRoleChangeRole() {
186+
return this.getGuild().getRoleById(this.colorRoleChangeRoleId);
187+
}
183188

184189
public TextChannel getNotificationThreadChannel() {
185190
return this.getGuild().getTextChannelById(this.notificationThreadChannelId);
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package net.discordjug.javabot.systems.color_roles;
2+
3+
import java.awt.Color;
4+
import java.util.List;
5+
import java.util.regex.Pattern;
6+
7+
import net.discordjug.javabot.data.config.BotConfig;
8+
import net.discordjug.javabot.util.Responses;
9+
import net.dv8tion.jda.api.entities.Role;
10+
import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent;
11+
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
12+
import net.dv8tion.jda.api.interactions.AutoCompleteQuery;
13+
import net.dv8tion.jda.api.interactions.commands.Command.Choice;
14+
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
15+
import net.dv8tion.jda.api.interactions.commands.OptionType;
16+
import net.dv8tion.jda.api.interactions.commands.build.Commands;
17+
import xyz.dynxsty.dih4jda.interactions.AutoCompletable;
18+
import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand;
19+
import xyz.dynxsty.dih4jda.util.AutoCompleteUtils;
20+
21+
/**
22+
* The {@code /color} command which allows users to change their own color with color roles.
23+
*/
24+
public class ChangeColorCommand extends SlashCommand implements AutoCompletable {
25+
26+
private static final Pattern COLOR_PATTERN = Pattern.compile("#[A-F0-9]{6}", Pattern.CASE_INSENSITIVE);
27+
28+
private final BotConfig config;
29+
private final ColorRoleService service;
30+
31+
/**
32+
* The constructor which sets the corresponding {@link net.dv8tion.jda.api.interactions.commands.build.SlashCommandData}.
33+
* @param botConfig The bot's configuration
34+
* @param service The {@link ColorRoleService} to actually change color roles
35+
*/
36+
public ChangeColorCommand(BotConfig botConfig, ColorRoleService service) {
37+
this.config = botConfig;
38+
this.service = service;
39+
setCommandData(Commands.slash("color", "Sets the color of your roles")
40+
.addOption(OptionType.STRING, "color", "The hex code of the role color (leave empty to remove your color roles)", false, true));
41+
}
42+
43+
@Override
44+
public void execute(SlashCommandInteractionEvent event) {
45+
Role baseRole = config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole();
46+
if (baseRole == null) {
47+
Responses.error(event, "This feature is not available.").queue();
48+
return;
49+
}
50+
if (!event.getMember().getRoles().contains(baseRole)) {
51+
Responses.error(event, "Only users with the " + baseRole.getAsMention() + " role can change their color.")
52+
.setAllowedMentions(List.of())
53+
.queue();
54+
return;
55+
}
56+
57+
String colorArgument = event.getOption("color", "", OptionMapping::getAsString);
58+
59+
Color color;
60+
if (colorArgument.isEmpty()) {
61+
color = null;
62+
} else {
63+
if (!colorArgument.startsWith("#")) {
64+
colorArgument = "#" + colorArgument;
65+
}
66+
if (!COLOR_PATTERN.matcher(colorArgument).matches()) {
67+
Responses.error(event, "'%s' is not a valid color", colorArgument).queue();
68+
return;
69+
}
70+
try {
71+
color = Color.decode(colorArgument);
72+
} catch (NumberFormatException e) {
73+
Responses.error(event, "'%s' is not a valid color", colorArgument).queue();
74+
return;
75+
}
76+
}
77+
78+
boolean removed = service.removeColor(event.getMember());
79+
if (color == null) {
80+
if (removed) {
81+
Responses.success(event, "Color removed", "Your color role has been removed.").queue();
82+
} else {
83+
Responses.error(event, "You don't have a color role.").queue();
84+
}
85+
return;
86+
}
87+
service.addColor(event.getMember(), color);
88+
Responses.success(event, "Color set", "Your color has been set to `%s`.", service.encodeColor(color)).queue();
89+
}
90+
91+
@Override
92+
public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) {
93+
Role baseRole = config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole();
94+
if (baseRole == null) {
95+
event.replyChoice("Feature unavailable", "This feature is not available.").queue();
96+
return;
97+
}
98+
if (!event.getMember().getRoles().contains(baseRole)) {
99+
event.replyChoice("Not permitted", "You cannot use this feature.").queue();
100+
return;
101+
}
102+
List<Choice> choices = service.getColorRoles(event.getGuild())
103+
.stream()
104+
.map(rc -> new Choice(service.encodeColor(rc.color()), service.encodeColor(rc.color())))
105+
.toList();
106+
event.replyChoices(AutoCompleteUtils.filterChoices(event, choices)).queue();
107+
}
108+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package net.discordjug.javabot.systems.color_roles;
2+
3+
import java.awt.Color;
4+
import java.util.Objects;
5+
6+
/**
7+
* A self-assignable color role.
8+
* @param guildId The Discord ID of the guild the role is a part of
9+
* @param roleId The Discord ID of the role
10+
* @param color The color of the role
11+
* @see ChangeColorCommand
12+
* @see ColorRoleService
13+
*/
14+
public record ColorRole(long guildId, long roleId, Color color) {
15+
public ColorRole {
16+
Objects.requireNonNull(color);
17+
}
18+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package net.discordjug.javabot.systems.color_roles;
2+
3+
import java.awt.Color;
4+
import java.util.List;
5+
6+
import net.discordjug.javabot.data.config.BotConfig;
7+
import net.discordjug.javabot.systems.color_roles.dao.ColorRoleRepository;
8+
import net.dv8tion.jda.api.entities.Guild;
9+
import net.dv8tion.jda.api.entities.Member;
10+
import net.dv8tion.jda.api.entities.Role;
11+
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent;
12+
import net.dv8tion.jda.api.events.guild.member.GuildMemberUpdateEvent;
13+
import net.dv8tion.jda.api.hooks.ListenerAdapter;
14+
import org.springframework.stereotype.Service;
15+
16+
/**
17+
* Service class for assigning and removing color roles.
18+
* @see ColorRoleService#addColor(Member, Color)
19+
* @see ColorRoleService#removeColor(Member)
20+
*/
21+
@Service
22+
public class ColorRoleService extends ListenerAdapter {
23+
24+
private final ColorRoleRepository repo;
25+
private final BotConfig config;
26+
27+
public ColorRoleService(ColorRoleRepository repo, BotConfig config) {
28+
this.repo = repo;
29+
this.config = config;
30+
}
31+
32+
/**
33+
* Adds a color role to a user.
34+
*
35+
* If a color role with the given color exists already, it will be reused. Otherwise, it will create a new one.
36+
* @param member The member to add the role to
37+
* @param color The color of the role to assign
38+
*/
39+
public synchronized void addColor(Member member, Color color) {
40+
repo.getFromColor(member.getGuild().getIdLong(), color)
41+
.ifPresentOrElse(colorRole -> {
42+
member.getGuild().addRoleToMember(member, member.getGuild().getRoleById(colorRole.roleId())).queue();
43+
repo.addRoleToMember(colorRole.roleId(), member.getIdLong());
44+
}, () -> {
45+
member.getGuild().createRole()
46+
.setName(encodeColor(color))
47+
.setColor(color)
48+
.queue(role -> {
49+
member.getGuild().addRoleToMember(member, role).queue();
50+
repo.addColorRole(new ColorRole(member.getGuild().getIdLong(), role.getIdLong(), color), member.getIdLong());
51+
member.getGuild().modifyRolePositions()
52+
.selectPosition(role)
53+
.moveAbove(config.get(member.getGuild()).getModerationConfig().getColorRoleChangeRole())
54+
.queue();
55+
});
56+
});
57+
}
58+
59+
/**
60+
* Removes all color roles from a user.
61+
*
62+
* If no other members have that role, the role will be deleted.
63+
* @param member The member to remove the role from
64+
* @return {@code true} if any color roles were removed, else {@code false}
65+
*/
66+
public synchronized boolean removeColor(Member member) {
67+
boolean changed = false;
68+
for (ColorRole color : repo.getColorRolesOfMember(member.getGuild().getIdLong(), member.getIdLong())) {
69+
repo.removeRoleFromMember(color.roleId(), member.getIdLong());
70+
Role role = member.getGuild().getRoleById(color.roleId());
71+
if (repo.cleanRoleIfNoMembers(color.roleId())) {
72+
role.delete().queue();
73+
} else {
74+
member.getGuild().removeRoleFromMember(member, role).queue();
75+
}
76+
changed = true;
77+
}
78+
return changed;
79+
}
80+
81+
@Override
82+
public void onGuildMemberUpdate(GuildMemberUpdateEvent event) {
83+
// Remove color roles if a member if they lost the relevant role
84+
// since members are not cached, we cannot use specific event types
85+
if (!event.getMember().getUnsortedRoles().contains(config.get(event.getGuild()).getModerationConfig().getColorRoleChangeRole())) {
86+
removeColor(event.getMember());
87+
}
88+
}
89+
90+
@Override
91+
public synchronized void onGuildMemberRemove(GuildMemberRemoveEvent event) {
92+
for (ColorRole color : repo.getColorRolesOfMember(event.getGuild().getIdLong(), event.getUser().getIdLong())) {
93+
repo.removeRoleFromMember(color.roleId(), event.getUser().getIdLong());
94+
Role role = event.getGuild().getRoleById(color.roleId());
95+
if (repo.cleanRoleIfNoMembers(color.roleId())) {
96+
role.delete().queue();
97+
}
98+
}
99+
}
100+
101+
public List<ColorRole> getColorRoles(Guild guild) {
102+
return repo.getColorRoles(guild.getIdLong());
103+
}
104+
105+
public String encodeColor(Color color) {
106+
return String.format("#%2h%2h%2h", color.getRed(), color.getGreen(), color.getBlue()).replace(' ', '0');
107+
}
108+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package net.discordjug.javabot.systems.color_roles.dao;
2+
3+
import java.awt.Color;
4+
import java.util.List;
5+
import java.util.Optional;
6+
7+
import javax.annotation.CheckReturnValue;
8+
9+
import lombok.RequiredArgsConstructor;
10+
import net.discordjug.javabot.systems.color_roles.ColorRole;
11+
import org.springframework.dao.EmptyResultDataAccessException;
12+
import org.springframework.jdbc.core.JdbcTemplate;
13+
import org.springframework.stereotype.Repository;
14+
15+
/**
16+
* Repository class for the COLOR_ROLES and COLOR_ROLE_MEMBERS tables which are used for role colors.
17+
*/
18+
@Repository
19+
@RequiredArgsConstructor
20+
public class ColorRoleRepository {
21+
private final JdbcTemplate jdbcTemplate;
22+
23+
/**
24+
* Gets all color roles of a guild.
25+
* @param guildId The ID of the guild
26+
* @return A {@link List} of all registered role colors in the guild
27+
*/
28+
public List<ColorRole> getColorRoles(long guildId) {
29+
return jdbcTemplate.query("SELECT ROLE_ID, COLOR FROM COLOR_ROLES WHERE GUILD_ID=?",
30+
(rs, _) -> new ColorRole(guildId, rs.getLong(1), new Color(rs.getInt(2))),
31+
guildId);
32+
}
33+
34+
/**
35+
* Tries to find a color role given the color.
36+
* @param guildId The ID of the guild containing the role
37+
* @param color The color of the role to search for
38+
* @return The color role if it exists or {@link Optional#empty()}
39+
*/
40+
public Optional<ColorRole> getFromColor(long guildId, Color color) {
41+
try {
42+
ColorRole result = jdbcTemplate.queryForObject("SELECT ROLE_ID FROM COLOR_ROLES WHERE GUILD_ID=? AND COLOR=?",
43+
(rs, _) -> new ColorRole(guildId, rs.getLong(1), color),
44+
guildId, color.getRGB());
45+
return Optional.ofNullable(result);
46+
} catch(EmptyResultDataAccessException _) {
47+
return Optional.empty();
48+
}
49+
}
50+
51+
/**
52+
* Gets all color roles of a given member.
53+
* @param guildId The ID of the guild the roles should be part of
54+
* @param memberId The ID of the member get the roles of
55+
* @return All color roles of the given member in the given guild
56+
*/
57+
public List<ColorRole> getColorRolesOfMember(long guildId, long memberId) {
58+
return jdbcTemplate.query("""
59+
SELECT cr.ROLE_ID, cr.COLOR
60+
FROM COLOR_ROLES cr
61+
INNER JOIN COLOR_ROLE_MEMBERS crm ON cr.ROLE_ID = crm.ROLE_ID
62+
WHERE cr.GUILD_ID=? AND crm.USER_ID=?
63+
""",
64+
(rs, _) -> new ColorRole(guildId, rs.getLong(1), new Color(rs.getInt(2))),
65+
guildId, memberId);
66+
}
67+
68+
/**
69+
* Registers that a member has a given color role.
70+
* @param color The color role
71+
* @param memberId The member the role is being added to
72+
*/
73+
public void addColorRole(ColorRole color, long memberId) {
74+
jdbcTemplate.update("INSERT INTO COLOR_ROLES (ROLE_ID, GUILD_ID, COLOR) VALUES (?,?,?)",
75+
color.roleId(), color.guildId(), color.color().getRGB());
76+
addRoleToMember(color.roleId(), memberId);
77+
}
78+
79+
/**
80+
* Registers that a color role has been added to a member.
81+
* @param roleId The ID of the role the member has
82+
* @param memberId The ID of the member to update
83+
*/
84+
public void addRoleToMember(long roleId, long memberId) {
85+
jdbcTemplate.update("INSERT INTO COLOR_ROLE_MEMBERS (ROLE_ID, USER_ID) VALUES (?,?)", roleId, memberId);
86+
}
87+
88+
/**
89+
* Registers that a color role has been added to a member.
90+
* @param roleId The ID of the role removed from the member
91+
* @param memberId The ID of the member to update
92+
* @see #cleanRoleIfNoMembers(long)
93+
*/
94+
public void removeRoleFromMember(long roleId, long memberId) {
95+
jdbcTemplate.update("DELETE FROM COLOR_ROLE_MEMBERS WHERE ROLE_ID=? AND USER_ID=?", roleId, memberId);
96+
}
97+
98+
/**
99+
* Removes a role from the database if it has no members.
100+
*
101+
* Callers should make sure to actually delete the role if this method returns {@code true}.
102+
* @param roleId The ID of the role to possibly remove
103+
* @return {@code true} if the role is registered as removed due to not having any members, else {@code false}
104+
*/
105+
@CheckReturnValue
106+
public boolean cleanRoleIfNoMembers(long roleId) {
107+
return jdbcTemplate.update("DELETE FROM COLOR_ROLES WHERE ROLE_ID=? AND (SELECT COUNT(*) FROM COLOR_ROLE_MEMBERS WHERE ROLE_ID=?)=0", roleId, roleId) > 0;
108+
}
109+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE COLOR_ROLES (
2+
ROLE_ID BIGINT NOT NULL PRIMARY KEY,
3+
GUILD_ID BIGINT NOT NULL,
4+
COLOR INTEGER NOT NULL UNIQUE
5+
);
6+
CREATE TABLE COLOR_ROLE_MEMBERS (
7+
ROLE_ID BIGINT NOT NULL,
8+
USER_ID BIGINT NOT NULL,
9+
PRIMARY KEY(ROLE_ID, USER_ID),
10+
FOREIGN KEY(ROLE_ID) REFERENCES COLOR_ROLES(ROLE_ID)
11+
);

‎src/main/resources/database/schema.sql‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,15 @@ CREATE TABLE form_submissions (
165165
);
166166

167167
CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id);
168+
169+
CREATE TABLE COLOR_ROLES (
170+
ROLE_ID BIGINT NOT NULL PRIMARY KEY,
171+
GUILD_ID BIGINT NOT NULL,
172+
COLOR INTEGER NOT NULL UNIQUE
173+
);
174+
CREATE TABLE COLOR_ROLE_MEMBERS (
175+
ROLE_ID BIGINT NOT NULL,
176+
USER_ID BIGINT NOT NULL,
177+
PRIMARY KEY(ROLE_ID, USER_ID),
178+
FOREIGN KEY(ROLE_ID) REFERENCES COLOR_ROLES(ROLE_ID)
179+
);

0 commit comments

Comments
 (0)