Skip to content

Commit

Permalink
feat: add player avatars to certain messages (closes #31) (#62)
Browse files Browse the repository at this point in the history
feat(chat): show player avatar on login, logout, death and teleport messages

feat(cmds): show avatar in custom command feedback where a player is detected
  • Loading branch information
axieum authored Oct 1, 2022
1 parent f90764a commit 331e6d8
Show file tree
Hide file tree
Showing 9 changed files with 189 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.Optional;

import net.dv8tion.jda.api.JDA;
import org.jetbrains.annotations.Nullable;

import net.minecraft.server.MinecraftServer;

Expand Down Expand Up @@ -36,4 +37,15 @@ static Minecord getInstance()
* @return the JDA client if built
*/
Optional<JDA> getJDA();

/**
* Builds and returns a URL for retrieving a Minecraft player's avatar.
*
* @param username the Minecraft player username or UUID
* @param height the desired height of the avatar in pixels
* @return the URL for the Minecraft player's avatar if enabled
* @see me.axieum.mcmod.minecord.impl.config.MiscConfig#enableAvatars
* @see me.axieum.mcmod.minecord.impl.config.MiscConfig#avatarUrl
*/
Optional<String> getAvatarUrl(@Nullable String username, int height);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import me.axieum.mcmod.minecord.api.addon.MinecordAddon;
import me.axieum.mcmod.minecord.api.event.JDAEvents;
import me.axieum.mcmod.minecord.api.event.ServerShutdownCallback;
import me.axieum.mcmod.minecord.api.util.StringTemplate;
import me.axieum.mcmod.minecord.impl.callback.DiscordLifecycleListener;
import me.axieum.mcmod.minecord.impl.callback.ServerLifecycleCallback;
import me.axieum.mcmod.minecord.impl.config.MinecordConfig;
Expand Down Expand Up @@ -97,6 +98,18 @@ public Optional<JDA> getJDA()
return Optional.ofNullable(client);
}

@Override
public Optional<String> getAvatarUrl(@Nullable String username, int height)
{
// Only return an avatar URL if they are enabled and the provided username is valid
if (getConfig().misc.enableAvatars && username != null && !username.isBlank()) {
return Optional.ofNullable(
new StringTemplate().add("username", username).add("size", height).format(getConfig().misc.avatarUrl)
);
}
return Optional.empty();
}

/**
* Returns the Minecord config instance.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public class MinecordConfig extends PartitioningSerializer.GlobalData
@Category("i18n")
public I18nConfig i18n = new I18nConfig();

@Category("misc")
public MiscConfig misc = new MiscConfig();

/**
* Registers and prepares a new configuration instance.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package me.axieum.mcmod.minecord.impl.config;

import me.shedaniel.autoconfig.ConfigData;
import me.shedaniel.autoconfig.annotation.Config;
import me.shedaniel.cloth.clothconfig.shadowed.blue.endless.jankson.Comment;
import static net.dv8tion.jda.api.EmbedBuilder.URL_PATTERN;

@Config(name = "misc")
public class MiscConfig implements ConfigData
{
@Comment("True if player avatars are included with embeds")
public boolean enableAvatars = true;

@Comment("""
The URL used for retrieving Minecraft player avatars
Usages: ${username} and ${size} (height in pixels)""")
public String avatarUrl = "https://minotar.net/helm/${username}/${size}";

@Override
public void validatePostLoad() throws ValidationException
{
// Validate the avatar URL is valid
if (avatarUrl == null || avatarUrl.isBlank()) {
throw new ValidationException("The avatar URL cannot be blank!");
} else if (avatarUrl.length() > 2000) {
throw new ValidationException("The avatar URL cannot be longer than 2000 characters!");
} else if (!URL_PATTERN.matcher(avatarUrl).matches()) {
throw new ValidationException("The avatar URL must be a valid http(s) or attachment url!");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ public void afterChangeWorld(ServerPlayerEntity player, ServerWorld origin, Serv
* Dispatch the message.
*/

DiscordDispatcher.embed((embed, entry) ->
embed.setDescription(st.format(entry.discord.teleport)),
entry -> entry.discord.teleport != null && entry.hasWorld(dest));
DiscordDispatcher.embedWithAvatar(
(embed, entry) -> embed.setDescription(st.format(entry.discord.teleport)),
entry -> entry.discord.teleport != null && entry.hasWorld(dest),
player.getUuidAsString()
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ public void onPlayReady(ServerPlayNetworkHandler handler, PacketSender sender, M
* Dispatch the message.
*/

DiscordDispatcher.embed((embed, entry) ->
embed.setDescription(st.format(entry.discord.join)),
entry -> entry.discord.join != null && entry.hasWorld(player.world));
DiscordDispatcher.embedWithAvatar(
(embed, entry) -> embed.setDescription(st.format(entry.discord.join)),
entry -> entry.discord.join != null && entry.hasWorld(player.world),
player.getUuidAsString()
);
});
}

Expand Down Expand Up @@ -82,9 +84,11 @@ public void onPlayDisconnect(ServerPlayNetworkHandler handler, MinecraftServer s
* Dispatch the message.
*/

DiscordDispatcher.embed((embed, entry) ->
embed.setDescription(st.format(entry.discord.leave)),
entry -> entry.discord.leave != null && entry.hasWorld(player.world));
DiscordDispatcher.embedWithAvatar(
(embed, entry) -> embed.setDescription(st.format(entry.discord.leave)),
entry -> entry.discord.leave != null && entry.hasWorld(player.world),
player.getUuidAsString()
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ public void onPlayerDeath(ServerPlayerEntity player, DamageSource source)
* Dispatch the message.
*/

DiscordDispatcher.embed((embed, entry) ->
embed.setDescription(st.format(entry.discord.death)),
entry -> entry.discord.death != null && entry.hasWorld(player.world));
DiscordDispatcher.embedWithAvatar(
(embed, entry) -> embed.setDescription(st.format(entry.discord.death)),
entry -> entry.discord.death != null && entry.hasWorld(player.world),
player.getUuidAsString()
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ public static void embed(BiConsumer<EmbedBuilder, ChatEntrySchema> builder, Pred
embed(builder, (action, entry) -> action.queue(), predicate);
}

/**
* Builds and queues embed messages with Minecraft player avatars for each configured chat entry.
*
* @param builder consumer to modify the Discord embed builder for a chat entry before queuing
* @param predicate predicate that filters configured chat entries
* @param username Minecraft player username for the avatar embed thumbnail
* @see #embedWithAvatar(BiConsumer, Predicate, String)
*/
public static void embedWithAvatar(
BiConsumer<EmbedBuilder, ChatEntrySchema> builder,
Predicate<ChatEntrySchema> predicate,
@Nullable String username
)
{
embedWithAvatar(builder, (action, entry) -> action.queue(), predicate, username);
}

/**
* Builds and acts on embed messages for each configured chat entry.
*
Expand All @@ -55,7 +72,33 @@ public static void embed(
dispatch(
(message, entry) ->
builder.andThen((m, e) -> message.setEmbeds(m.build()))
.accept(new EmbedBuilder(), entry),
.accept(new EmbedBuilder(), entry),
action,
predicate
);
}

/**
* Builds and acts on embed messages with Minecraft player avatars for each configured chat entry.
*
* @param builder consumer to modify the Discord embed builder for a chat entry before queuing
* @param action consumer to act upon the resulting Discord message action
* @param predicate predicate that filters configured chat entries
* @param username Minecraft player username for the avatar embed thumbnail
* @see #embed(BiConsumer, BiConsumer, Predicate)
*/
public static void embedWithAvatar(
BiConsumer<EmbedBuilder, ChatEntrySchema> builder,
BiConsumer<MessageAction, ChatEntrySchema> action,
Predicate<ChatEntrySchema> predicate,
@Nullable String username
)
{
embed(
(message, entry) -> {
Minecord.getInstance().getAvatarUrl(username, 16).ifPresent(message::setThumbnail);
builder.accept(message, entry);
},
action,
predicate
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package me.axieum.mcmod.minecord.impl.cmds.command.discord;

import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;

import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
Expand All @@ -12,8 +15,15 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import com.mojang.authlib.GameProfile;
import com.mojang.brigadier.ParseResults;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.context.ParsedCommandNode;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.tree.ArgumentCommandNode;

import net.minecraft.command.argument.EntityArgumentType;
import net.minecraft.command.argument.GameProfileArgumentType;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.command.CommandOutput;
import net.minecraft.server.command.ServerCommandSource;
Expand All @@ -22,6 +32,7 @@
import net.minecraft.util.math.Vec3d;
import net.minecraft.world.GameRules;

import me.axieum.mcmod.minecord.api.Minecord;
import me.axieum.mcmod.minecord.api.cmds.command.MinecordCommand;
import me.axieum.mcmod.minecord.api.cmds.event.MinecordCommandEvents;
import me.axieum.mcmod.minecord.api.util.StringTemplate;
Expand Down Expand Up @@ -101,15 +112,29 @@ public void execute(@NotNull SlashCommandInteractionEvent event, @Nullable Minec
);

// Attempt to proxy the Minecraft command
AtomicBoolean success = new AtomicBoolean(false);
AtomicInteger result = new AtomicInteger(0);
final AtomicBoolean success = new AtomicBoolean(false);
final AtomicInteger result = new AtomicInteger(0);
@Nullable CommandSyntaxException error = null;
try {
LOGGER.info("@{} is running '/{}'", tag, mcCommand);
server.getCommandManager().getDispatcher().execute(mcCommand, source.withConsumer((c, s, r) -> {
success.set(s); // if unsuccessful, it may choose to raise a command syntax exception
result.set(r);
}));

// Parse the command and build its context
final ParseResults<ServerCommandSource> parseResults = server.getCommandManager().getDispatcher().parse(
mcCommand,
source.withConsumer((c, s, r) -> {
success.set(s); // if unsuccessful, it may choose to raise a command syntax exception
result.set(r);
})
);
final CommandContext<ServerCommandSource> context = parseResults.getContext().build(mcCommand);

// Analyse the command context for a player's UUID to show their avatar on any command feedback
findPlayerUsernames(context.getLastChild()).findFirst()
.flatMap(name -> Minecord.getInstance().getAvatarUrl(name, 16))
.ifPresent(url -> output.thumbnailUrl = url);

// Execute the command
server.getCommandManager().getDispatcher().execute(parseResults);
} catch (CommandSyntaxException e) {
error = e;
} finally {
Expand All @@ -127,6 +152,7 @@ public void execute(@NotNull SlashCommandInteractionEvent event, @Nullable Minec
// NB: This is to prevent the "The application did not respond" error in Discord, e.g. '/say' or '/tellraw'
if (output.prevMessage == null) {
if (error == null) {
output.thumbnailUrl = null;
source.sendFeedback(Text.literal(getConfig().messages.feedback), false);
} else {
source.sendError(Text.literal(error.getMessage()));
Expand Down Expand Up @@ -166,6 +192,37 @@ private static String prepareCommand(@NotNull String command, List<OptionMapping
return result.length() > 0 && result.charAt(0) == '/' ? result.substring(1) : result;
}

/**
* Traverses the nodes of a Minecraft command context for a player-related
* argument and returns their UUID or username if present.
*
* @param context Minecraft command context
* @return a stream of Minecraft player usernames or UUIDs if present
*/
private static Stream<String> findPlayerUsernames(CommandContext<ServerCommandSource> context)
{
return context
.getNodes()
.stream()
.map(ParsedCommandNode::getNode)
.filter(node -> node instanceof ArgumentCommandNode)
.map(node -> (ArgumentCommandNode<?, ?>) node)
.map(node -> {
try {
if (node.getType() instanceof EntityArgumentType) {
// Entity
return EntityArgumentType.getPlayer(context, node.getName()).getUuidAsString();
} else if (node.getType() instanceof GameProfileArgumentType) {
// Game Profile
Collection<GameProfile> c = GameProfileArgumentType.getProfileArgument(context, node.getName());
return c.size() == 1 ? c.iterator().next().getId().toString() : null;
}
} catch (CommandSyntaxException | IllegalArgumentException ignored) { /* ignored */ }
return null;
})
.filter(Objects::nonNull);
}

/**
* A virtual Minecraft command output for use via Discord.
*/
Expand All @@ -176,6 +233,7 @@ private final class DiscordCommandOutput implements CommandOutput
private final String mcCommand;
public boolean erroneous = false;
public @Nullable String prevMessage = null;
public @Nullable String thumbnailUrl = null;

/**
* Constructs a new virtual command output for relaying feedback to Discord.
Expand All @@ -199,6 +257,8 @@ public void sendMessage(Text message)
EmbedBuilder embed = new EmbedBuilder()
// Set the colour to green for a success, and red for a failure
.setColor(!erroneous ? 0x00ff00 : 0xff0000)
// Set the thumbnail
.setThumbnail(thumbnailUrl)
// Set the message
.setDescription(text);

Expand Down

0 comments on commit 331e6d8

Please sign in to comment.