Skip to content

Commit 1119dbf

Browse files
committed
/watch and /guild-watch
1 parent 4e52795 commit 1119dbf

17 files changed

+1607
-21
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ AWS_SECRET_ACCESS_KEY=123123
99
DB_SYNC=false
1010
LIVE_FEEDS=false
1111
API_KEY=123123
12+
WATCHES=false

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,5 @@ Invite link: [bot.2b2t.vc](https://bot.2b2t.vc)
2525
* `/stats` -> Prints a player's stats on 2b2t
2626
* `/wordcount` -> Counts how many times a word has been seen in chat
2727
* `/search` -> Searches for chats containing a specific word
28+
* `/watch` -> DM Notifications on player joins, leaves, deaths, or kills
29+
* `/guild-watch` -> Discord server channel notifications on player joins, leaves, deaths, or kills

src/main/java/vc/Application.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ public void onDestroy() {
116116
this.scheduledExecutorService.shutdownNow();
117117
}
118118
if (this.gatewayDiscordClient != null) {
119-
this.gatewayDiscordClient.logout().block(Duration.ofSeconds(5));
119+
this.gatewayDiscordClient.logout().block(Duration.ofSeconds(15));
120120
}
121121
} catch (Exception e) {
122122
LOGGER.error("Error during shutdown", e);
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
package vc.commands;
2+
3+
import discord4j.common.util.Snowflake;
4+
import discord4j.core.event.domain.interaction.ChatInputInteractionEvent;
5+
import discord4j.core.object.command.ApplicationCommandInteractionOption;
6+
import discord4j.core.object.command.ApplicationCommandInteractionOptionValue;
7+
import discord4j.core.object.entity.Member;
8+
import discord4j.core.object.entity.Message;
9+
import discord4j.core.object.entity.channel.Channel;
10+
import discord4j.core.spec.EmbedCreateSpec;
11+
import discord4j.core.spec.MessageCreateSpec;
12+
import discord4j.core.util.MentionUtil;
13+
import discord4j.rest.http.client.ClientException;
14+
import discord4j.rest.util.Color;
15+
import discord4j.rest.util.Permission;
16+
import org.slf4j.Logger;
17+
import org.slf4j.LoggerFactory;
18+
import org.springframework.stereotype.Component;
19+
import reactor.core.publisher.Mono;
20+
import vc.api.model.ProfileData;
21+
import vc.api.model.ProfileDataImpl;
22+
import vc.commands.options.ChatInteractionOptionContext;
23+
import vc.config.watch.GuildWatchConfigRecord;
24+
import vc.config.watch.WatchConfigManager;
25+
import vc.live.watch.WatchManager;
26+
import vc.util.PlayerLookup;
27+
import vc.util.Validator;
28+
29+
import java.time.Duration;
30+
import java.time.Instant;
31+
import java.util.Collections;
32+
import java.util.Optional;
33+
import java.util.UUID;
34+
35+
@Component
36+
public class GuildWatchCommand implements SlashCommand {
37+
private static final Logger LOGGER = LoggerFactory.getLogger(GuildWatchCommand.class);
38+
private final WatchManager watchManager;
39+
private final WatchConfigManager watchConfigManager;
40+
private final PlayerLookup playerLookup;
41+
42+
public GuildWatchCommand(
43+
final WatchManager watchManager,
44+
final WatchConfigManager watchConfigManager,
45+
final PlayerLookup playerLookup
46+
) {
47+
this.watchManager = watchManager;
48+
this.watchConfigManager = watchConfigManager;
49+
this.playerLookup = playerLookup;
50+
}
51+
52+
@Override
53+
public String getName() {
54+
return "guild-watch";
55+
}
56+
57+
@Override
58+
public Mono<Message> handle(final ChatInputInteractionEvent event) {
59+
if (event.getInteraction().getGuildId().isEmpty()) return error(event, "This command can only be used inside a discord server");
60+
if (!validateUserPermissions(event)) return error(event, "You must have permission: " + Permission.MANAGE_MESSAGES + " to use this command");
61+
if (event.getOption("add").isPresent()) {
62+
var addOption = event.getOption("add").get();
63+
var ctx = resolveProfileSubOption(new ChatInteractionOptionContext(event), addOption);
64+
if (ctx.isErrorSet()) return error(event, ctx.getErrorMessage());
65+
var channelOption = addOption
66+
.getOption("channel")
67+
.flatMap(ApplicationCommandInteractionOption::getValue)
68+
.map(ApplicationCommandInteractionOptionValue::asChannel)
69+
.map(m -> m.block(Duration.ofSeconds(10)));
70+
if (channelOption.isEmpty()) {
71+
return error(event, "Channel option is required to add a watch");
72+
}
73+
var channel = channelOption.get();
74+
if (!testPermissions(event.getInteraction().getGuildId().get().asString(), channel)) {
75+
return error(event, "Bot must have permissions to send messages in: " + channel.getMention());
76+
}
77+
boolean joins = addOption.getOption("joins")
78+
.flatMap(ApplicationCommandInteractionOption::getValue)
79+
.map(ApplicationCommandInteractionOptionValue::asBoolean)
80+
.orElse(true);
81+
boolean leaves = addOption.getOption("leaves")
82+
.flatMap(ApplicationCommandInteractionOption::getValue)
83+
.map(ApplicationCommandInteractionOptionValue::asBoolean)
84+
.orElse(true);
85+
boolean chats = addOption.getOption("chats")
86+
.flatMap(ApplicationCommandInteractionOption::getValue)
87+
.map(ApplicationCommandInteractionOptionValue::asBoolean)
88+
.orElse(true);
89+
boolean deaths = addOption.getOption("deaths")
90+
.flatMap(ApplicationCommandInteractionOption::getValue)
91+
.map(ApplicationCommandInteractionOptionValue::asBoolean)
92+
.orElse(true);
93+
boolean kills = addOption.getOption("kills")
94+
.flatMap(ApplicationCommandInteractionOption::getValue)
95+
.map(ApplicationCommandInteractionOptionValue::asBoolean)
96+
.orElse(true);
97+
if (!joins && !leaves && !chats && !deaths && !kills) {
98+
return error(event, "At least one event type must be enabled");
99+
}
100+
String mentionUserId = "";
101+
String mentionRoleId = "";
102+
var mentionTarget = addOption.getOption("mention")
103+
.flatMap(ApplicationCommandInteractionOption::getValue)
104+
.map(ApplicationCommandInteractionOptionValue::asSnowflake);
105+
if (mentionTarget.isPresent()) {
106+
var snowflake = mentionTarget.get();
107+
var guild = event.getInteraction().getGuild().block(Duration.ofSeconds(10));
108+
try {
109+
Member member = guild.getMemberById(snowflake).block(Duration.ofSeconds(10));
110+
if (member != null) {
111+
mentionUserId = snowflake.asString();
112+
} else {
113+
LOGGER.warn("Mention target is not a member of the guild: {}", snowflake.asString());
114+
}
115+
} catch (Exception e) {
116+
}
117+
if (mentionUserId.isEmpty()) {
118+
try {
119+
var role = guild.getRoleById(snowflake).block(Duration.ofSeconds(10));
120+
if (role != null) {
121+
mentionRoleId = snowflake.asString();
122+
} else {
123+
LOGGER.warn("Mention target is not a role in the guild: {}", snowflake.asString());
124+
}
125+
} catch (Exception e) {
126+
}
127+
}
128+
if (mentionUserId.isEmpty() && mentionRoleId.isEmpty()) {
129+
return error(event, "Mention target must be a valid user or role");
130+
}
131+
}
132+
133+
var profile = ctx.profileData;
134+
var watch = new GuildWatchConfigRecord(
135+
Snowflake.of(Instant.now()).asString(),
136+
event.getInteraction().getGuildId().get().asString(),
137+
event.getInteraction().getGuild().block(Duration.ofSeconds(10)).getName(),
138+
channel.getId().asString(),
139+
joins,
140+
leaves,
141+
chats,
142+
deaths,
143+
kills,
144+
mentionUserId,
145+
mentionRoleId,
146+
profile.uuid(),
147+
profile.name()
148+
);
149+
var existingWatches = watchConfigManager.getGuildWatchesByGuild(event.getInteraction().getGuildId().get().asString());
150+
for (var w : existingWatches) {
151+
if (w.targetUuid().equals(profile.uuid())) {
152+
watchConfigManager.removeGuildWatchConfig(w);
153+
}
154+
}
155+
watchConfigManager.updateGuildWatchConfig(watch);
156+
return event.createFollowup()
157+
.withEmbeds(populateIdentity(EmbedCreateSpec.builder(), profile)
158+
.color(Color.SEA_GREEN)
159+
.description("""
160+
Watch added!
161+
162+
Notifications on watched events will be sent to: %s
163+
""".formatted(channel.getMention()))
164+
.thumbnail(profile.getAvatarURL())
165+
.build());
166+
} else if (event.getOption("delete").isPresent()) {
167+
var deleteOption = event.getOption("delete").get();
168+
var playerNameOption = deleteOption.getOption("player")
169+
.flatMap(ApplicationCommandInteractionOption::getValue)
170+
.map(ApplicationCommandInteractionOptionValue::asString);
171+
if (playerNameOption.isEmpty()) {
172+
return error(event, "Player name required");
173+
}
174+
var playerName = playerNameOption.get().trim();
175+
if (!Validator.isValidPlayerName(playerName)) {
176+
return error(event, "Invalid player name");
177+
}
178+
var profile = playerLookup.getPlayerIdentity(playerName)
179+
// fall back to a random UUID, so we can handle cases where the player's profile was deleted or name changed
180+
.orElse(new ProfileDataImpl(playerName, UUID.randomUUID()));
181+
var watches = watchConfigManager.getGuildWatchesByGuild(event.getInteraction().getGuildId().get().asString());
182+
for (var watch : watches) {
183+
if (watch.targetName().equalsIgnoreCase(playerName)) {
184+
watchConfigManager.removeGuildWatchConfig(watch);
185+
return event.createFollowup()
186+
.withEmbeds(populateIdentity(EmbedCreateSpec.builder(), profile)
187+
.color(Color.SEA_GREEN)
188+
.description("Watch deleted!")
189+
.thumbnail(profile.getAvatarURL())
190+
.build());
191+
}
192+
}
193+
return error(event, "No watch found for " + profile.name() + " (" + profile.uuid() + ")");
194+
} else if (event.getOption("list").isPresent()) {
195+
var watches = watchConfigManager.getGuildWatchesByGuild(event.getInteraction().getGuildId().get().asString());
196+
Collections.sort(watches, (a, b) -> {
197+
int c = a.channelId().compareToIgnoreCase(b.channelId());
198+
if (c != 0) return c;
199+
return a.targetName().compareToIgnoreCase(b.targetName());
200+
});
201+
StringBuilder builder = new StringBuilder();
202+
if (watches.isEmpty()) {
203+
builder.append("None!\n");
204+
} else {
205+
for (var watch : watches) {
206+
builder
207+
.append(escape(watch.targetName()));
208+
boolean anyEnabled = watch.joins() || watch.leaves() || watch.chats() || watch.deaths() || watch.kills();
209+
if (anyEnabled) {
210+
builder
211+
.append(" - ")
212+
.append(MentionUtil.forChannel(Snowflake.of(watch.channelId())))
213+
.append(" - ");
214+
if (watch.joins()) {
215+
builder.append("Joins, ");
216+
}
217+
if (watch.leaves()) {
218+
builder.append("Leaves, ");
219+
}
220+
if (watch.chats()) {
221+
builder.append("Chats, ");
222+
}
223+
if (watch.deaths()) {
224+
builder.append("Deaths, ");
225+
}
226+
if (watch.kills()) {
227+
builder.append("Kills, ");
228+
}
229+
// Remove trailing comma and space
230+
builder.setLength(builder.length() - 2);
231+
} else {
232+
builder.append(" - Disabled");
233+
}
234+
builder
235+
.append("\n");
236+
}
237+
}
238+
var description = builder.toString();
239+
if (description.length() > 4000) {
240+
description = description.substring(0, 4000) + "\n... (truncated)";
241+
}
242+
return event.createFollowup()
243+
.withEmbeds(EmbedCreateSpec.builder()
244+
.title("Watch List")
245+
.description(description)
246+
.color(Color.CYAN)
247+
.build());
248+
} else if (event.getOption("clear").isPresent()) {
249+
var watches = watchConfigManager.getGuildWatchesByGuild(event.getInteraction().getGuildId().get().asString());
250+
for (var watch : watches) {
251+
watchConfigManager.removeGuildWatchConfig(watch);
252+
}
253+
return event.createFollowup()
254+
.withEmbeds(EmbedCreateSpec.builder()
255+
.title("All Watches Cleared")
256+
.description("Removed " + watches.size() + " watches.")
257+
.color(Color.CYAN)
258+
.build());
259+
}
260+
return error(event, "Unknown command option");
261+
}
262+
263+
private ChatInteractionOptionContext resolveProfileSubOption(final ChatInteractionOptionContext ctx, final ApplicationCommandInteractionOption parentOption) {
264+
var playerNameOptional = parentOption.getOption("player").flatMap(ApplicationCommandInteractionOption::getValue);
265+
if(playerNameOptional.isEmpty()) {
266+
ctx.setError("Player name required");
267+
return ctx;
268+
}
269+
String playerName = playerNameOptional.get().asString().trim();
270+
if (!Validator.isValidPlayerName(playerName)) {
271+
ctx.setError("Invalid player name");
272+
return ctx;
273+
}
274+
Optional<ProfileData> playerIdentity = playerLookup.getPlayerIdentity(playerName);
275+
if (playerIdentity.isEmpty()) {
276+
ctx.setError("No player named `" + playerName + "` exists");
277+
return ctx;
278+
}
279+
ctx.profileData = playerIdentity.get();
280+
return ctx;
281+
}
282+
283+
private boolean testPermissions(final String guildId, final Channel channel) {
284+
try {
285+
var embed = EmbedCreateSpec.builder()
286+
.description("✔ Watch Notifications Permissions Test Success! ✔")
287+
.color(Color.MEDIUM_SEA_GREEN)
288+
.build();
289+
var msg = MessageCreateSpec.builder()
290+
.addEmbed(embed)
291+
.build()
292+
.asRequest();
293+
channel.getRestChannel().createMessage(msg)
294+
.block();
295+
return true;
296+
} catch (final ClientException clientException) {
297+
if (clientException.getStatus().code() == 403) {
298+
LOGGER.warn("Missing permissions for guild: {}, in channel: {}", guildId, channel.getId().asString());
299+
return false;
300+
} else {
301+
LOGGER.warn("Failed testing permissions for guild: {}, in channel: {} - [{}] {}", guildId, channel.getId().asString(), clientException.getStatus().code(), clientException.getMessage());
302+
}
303+
} catch (final Throwable e) {
304+
LOGGER.warn("Failed testing permissions for guild: {}, in channel: {}", guildId, channel.getId().asString(), e);
305+
}
306+
return false;
307+
}
308+
309+
private boolean validateUserPermissions(final ChatInputInteractionEvent event) {
310+
return event.getInteraction().getMember()
311+
.map(member -> member.getBasePermissions().block())
312+
.map(perms -> perms.contains(Permission.MANAGE_MESSAGES) || perms.contains(Permission.ADMINISTRATOR))
313+
.orElse(false);
314+
}
315+
316+
}

0 commit comments

Comments
 (0)