Skip to content

Commit dae1661

Browse files
author
ZDiscord Maintainer
committed
Add /profile, /seen, /following, /confess; FollowModule; achievement rarity; silent 5h update checks; Discord markdown color codes
Major feature additions (all four surfaces are new in this commit): /profile [player] — rich player passport embed (avatar, NameMC link, first/last seen, sessions, total playtime, advancement count, link status, follower count, online indicator) with a Follow/Unfollow button that DMs the requester whenever the player logs in. /seen <player> — quick last-seen lookup with relative Discord timestamps and playtime. /following — list of Minecraft players the requester follows. /confess <message> — anonymous confessions to a configured channels.confessions; confessors get a stable Confessor #XXXX handle. Under the hood: * FollowModule (new) — in-memory player→follower cache, JDA openPrivateChannel() DM dispatch on join, button handler. * FollowModule dispatches in parallel via PlatformAdapter. * StorageManager + YamlStorage + MySQLStorage grew 12 new methods (last_seen, first_join, sessions, advancement unlock ledger, follow graph). YAML uses three new files; MySQL uses three new tables. * AdvancementListener now persists unlocks and reads rarity stats in an async hop; the embed gains a First-of-the-day badge (1 unlockers) or a Rare-N% badge (<25% of active). * UpdateChecker now polls every 5h (was 6) and posts a single quiet embed to misc.update-channel via postSilentDiscordNotice(); AtomicBoolean guards the one-time post. misc.update-silent suppresses the in-game admin banner for operators who want only the Discord notice. * ColorUtil.toDiscordMarkdown converts &l/&o/&n/&m to Discord markdown (** * __ ~~); colour codes are dropped. Applied to every player-visible embed field that previously went through ColorUtil.stripColor (so player authors of confessions, welcome messages, and embed templates keep their formatting). * PlayerProfileBuilder — single source of truth for the profile-card embed. LeaderboardModule.getStat added for per-player stat lookups. Config additions: channels.confessions — /confess target channel misc.update-silent — suppress in-game update banner misc.update-channel — silent Discord notice channel profile.embed.color — profile card colour Tests: * 8 new ColorUtil.toDiscordMarkdown cases (bold/italic/ underline/strike/colour drop/section-sign/nested runs/null). * 5 new YamlStorage cases (lastSeen monotonic, firstJoin set-once, session counter, advancement idempotence, follower round-trip). Version stays at 1.1.0; the release workflow will republish v1.1.0 on the next green push.
1 parent 432eb73 commit dae1661

17 files changed

Lines changed: 1717 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,75 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
## [Unreleased]
88

99
### Added
10+
- `/profile [player]` Discord slash command that renders a rich
11+
"player passport" embed: avatar, NameMC link, first/last seen
12+
(relative Discord timestamps), session count, total playtime,
13+
advancement count, link status, follower count, and online
14+
indicator. A **Follow** / **Unfollow** button on the embed
15+
subscribes the requester to DM notifications whenever the
16+
player logs in.
17+
- `/seen <player>` Discord slash command for quick last-seen
18+
lookups. Returns online/offline status, last-seen timestamp,
19+
total tracked playtime, and session count.
20+
- `/following` Discord slash command listing the Minecraft
21+
players the requester currently follows.
22+
- `/confess <message>` Discord slash command that posts an
23+
anonymous confession to a configured `channels.confessions`
24+
channel. Each confessor gets a stable "Confessor #XXXX" handle
25+
derived from their user id hash. `&` colour codes in the
26+
message are converted to Discord markdown.
27+
- Silent update check every 5 hours (was 6). When a new release
28+
is detected, a single quiet embed is posted to
29+
`misc.update-channel` (no pings, no looping) and a
30+
`misc.update-silent` flag suppresses the in-game admin banner
31+
for operators who want to keep console/Discord as the only
32+
signal.
33+
- Achievement **rarity display**: the advancement announcement
34+
embed now includes a "First of the day" badge when the player
35+
is the first to unlock that advancement in the last 24 hours,
36+
and a "Rare — only N% of players have this" badge when fewer
37+
than 25% of active players have ever unlocked it.
38+
- Player activity storage (per-player `last_seen`, `first_join`,
39+
`sessions`, advancement unlock ledger, follow relationships).
40+
YAML uses new `player_activity.yml`, `advancement_unlocks.yml`,
41+
and `player_follows.yml` files; MySQL uses three new tables.
42+
- `ColorUtil.toDiscordMarkdown(text)` converts Minecraft
43+
formatting codes (`&l` bold, `&o` italic, `&n` underline,
44+
`&m` strikethrough) to Discord markdown; colour codes (0-9,
45+
a-f) are dropped. Applied to all player-visible embed text.
46+
- `FollowModule` keeps an in-memory cache of player→follower
47+
sets and dispatches DM notifications on join via JDA's
48+
`User.openPrivateChannel()` API. The cache is populated
49+
lazily on the first join and refreshed on add/remove.
50+
- `LeaderboardModule.getStat(uuid, stat)` for cheap per-player
51+
stat lookups (used by the profile card and `/seen`).
52+
- `PlayerProfileBuilder` util — single source of truth for the
53+
profile-card embed.
54+
55+
### Changed
56+
- `YamlStorage` and `MySQLStorage` grew three new files / three
57+
new tables each, plus 12 new methods on the
58+
`StorageManager` interface.
59+
- `AdvancementListener` now persists the unlock to storage and
60+
reads the rarity stats in an async hop before posting the
61+
embed on the main thread (so a slow disk can't stall the
62+
event).
63+
- `UpdateChecker` interval is now 5 hours (was 6). The
64+
`postSilentDiscordNotice(...)` helper only fires once per
65+
detected release thanks to an `AtomicBoolean` guard.
66+
- `JoinQuitListener` writes `last_seen` and increments the
67+
session counter on every join; the same on every quit, so
68+
the timestamp stays fresh even if the player disconnects
69+
abnormally.
70+
- `ColorUtil.stripColor` is now only used internally for
71+
pre-processing before `toDiscordMarkdown` runs; the embed
72+
fields use the new converter so formatting is preserved.
73+
74+
### Fixed
75+
- `JoinQuitListener` no longer double-calls the in-game
76+
welcome message on Paper (the `first-join-message` is now
77+
also rendered through `toDiscordMarkdown` so it carries its
78+
formatting into the events channel embed).
1079
- MockBukkit test framework with JUnit 5. The build workflow now runs
1180
the unit test suite in CI; new tests cover config loading, ticket
1281
category parsing, status embed structure, update-checker version

src/main/java/dev/demonz/zdiscord/ZDiscord.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import dev.demonz.zdiscord.modules.CommandLoggerModule;
3636
import dev.demonz.zdiscord.modules.ConsoleModule;
3737
import dev.demonz.zdiscord.modules.EmbedBuilderModule;
38+
import dev.demonz.zdiscord.modules.FollowModule;
3839
import dev.demonz.zdiscord.modules.LeaderboardModule;
3940
import dev.demonz.zdiscord.modules.LinkModule;
4041
import dev.demonz.zdiscord.modules.PerformanceModule;
@@ -87,6 +88,7 @@ public class ZDiscord extends JavaPlugin {
8788
private StaffChatModule staffChatModule;
8889
private VoiceStatusModule voiceStatusModule;
8990
private ConsoleModule consoleModule;
91+
private FollowModule followModule;
9092

9193
@Override
9294
public void onEnable() {
@@ -144,6 +146,7 @@ public void onDisable() {
144146
if (staffChatModule != null) staffChatModule.shutdown();
145147
if (voiceStatusModule != null) voiceStatusModule.shutdown();
146148
if (consoleModule != null) consoleModule.shutdown();
149+
if (followModule != null) followModule.shutdown();
147150

148151
if (storageManager != null) storageManager.shutdown();
149152
if (webhookManager != null) webhookManager.shutdown();
@@ -244,6 +247,9 @@ private void initModules() {
244247
voiceStatusModule.init();
245248
}
246249

250+
followModule = new FollowModule(this);
251+
followModule.init();
252+
247253
getLogger().info("Modules initialised.");
248254
}
249255

@@ -295,6 +301,7 @@ public void reload() {
295301
if (commandLoggerModule != null) commandLoggerModule.reload();
296302
if (staffChatModule != null) staffChatModule.reload();
297303
if (voiceStatusModule != null) voiceStatusModule.reload();
304+
if (followModule != null) followModule.reload();
298305

299306
if (botManager != null && botManager.isConnected()) {
300307
botManager.updateActivity();
@@ -386,6 +393,10 @@ public ConsoleModule getConsoleModule() {
386393
return consoleModule;
387394
}
388395

396+
public FollowModule getFollowModule() {
397+
return followModule;
398+
}
399+
389400
public void debug(String message) {
390401
if (configManager.getBoolean("misc.debug", false)) {
391402
getLogger().log(Level.INFO, "[debug] " + message);

src/main/java/dev/demonz/zdiscord/discord/SlashCommandManager.java

Lines changed: 219 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,24 @@
1818

1919
import dev.demonz.zdiscord.ZDiscord;
2020
import dev.demonz.zdiscord.util.ColorUtil;
21+
import dev.demonz.zdiscord.util.HeadUtil;
2122
import dev.demonz.zdiscord.util.PlaceholderUtil;
23+
import dev.demonz.zdiscord.util.PlayerProfileBuilder;
2224
import dev.demonz.zdiscord.util.StatusEmbedBuilder;
2325
import dev.demonz.zdiscord.util.TPSUtil;
2426
import net.dv8tion.jda.api.EmbedBuilder;
2527
import net.dv8tion.jda.api.entities.Guild;
2628
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
29+
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
2730
import net.dv8tion.jda.api.hooks.ListenerAdapter;
2831
import net.dv8tion.jda.api.interactions.commands.OptionType;
2932
import net.dv8tion.jda.api.interactions.commands.build.Commands;
3033
import org.bukkit.Bukkit;
34+
import org.bukkit.OfflinePlayer;
3135
import org.bukkit.entity.Player;
3236

3337
import java.time.Instant;
38+
import java.util.UUID;
3439
import java.util.stream.Collectors;
3540

3641
/**
@@ -70,7 +75,17 @@ public void registerCommands() {
7075
"Quick setup: module name (chat, status, events, etc.)",
7176
false, true)
7277
.addOption(OptionType.CHANNEL, "channel",
73-
"Quick setup: target channel", false))
78+
"Quick setup: target channel", false),
79+
Commands.slash("profile", "View a Minecraft player's profile card")
80+
.addOption(OptionType.STRING, "player",
81+
"Player name (omit for yourself)", false),
82+
Commands.slash("seen", "When was a player last online?")
83+
.addOption(OptionType.STRING, "player",
84+
"Player name", true),
85+
Commands.slash("following", "List the Minecraft players you follow"),
86+
Commands.slash("confess", "Post an anonymous confession to the confessions channel")
87+
.addOption(OptionType.STRING, "message",
88+
"What do you want to confess?", true))
7489
.queue(
7590
success -> plugin.getLogger().info("Registered "
7691
+ success.size() + " slash commands."),
@@ -107,6 +122,18 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
107122
case "leaderboard":
108123
handleLeaderboard(event);
109124
break;
125+
case "profile":
126+
handleProfile(event);
127+
break;
128+
case "seen":
129+
handleSeen(event);
130+
break;
131+
case "following":
132+
handleFollowing(event);
133+
break;
134+
case "confess":
135+
handleConfess(event);
136+
break;
110137
default:
111138
break;
112139
}
@@ -242,4 +269,195 @@ private void handleLeaderboard(SlashCommandInteractionEvent event) {
242269
String stat = event.getOption("stat").getAsString();
243270
plugin.getLeaderboardModule().sendLeaderboard(event, stat);
244271
}
272+
273+
private void handleProfile(SlashCommandInteractionEvent event) {
274+
// The work below does offline-player lookups + a few storage
275+
// reads; we run it off the event handler thread to keep the
276+
// JDA thread responsive even on a slow disk.
277+
event.deferReply().queue();
278+
String queryName = event.getOption("player") == null
279+
? null
280+
: event.getOption("player").getAsString();
281+
String discordTag = event.getUser().getAsTag();
282+
283+
plugin.getPlatformAdapter().runAsync(() -> {
284+
OfflinePlayer target = resolveProfileTarget(queryName, event.getUser().getId());
285+
if (target == null) {
286+
String msg = queryName == null
287+
? "You don't have a linked Minecraft account yet. "
288+
+ "Run `/link <code>` after `/zdiscord link` in-game, "
289+
+ "or pass `player:<name>` to look up someone else."
290+
: "No player named **" + queryName + "** has joined this server before.";
291+
event.getHook().sendMessage(msg).setEphemeral(true).queue();
292+
return;
293+
}
294+
295+
PlayerProfileBuilder.Profile profile =
296+
PlayerProfileBuilder.build(plugin, target);
297+
EmbedBuilder embed = PlayerProfileBuilder.toEmbed(plugin, profile, discordTag);
298+
299+
if (plugin.getFollowModule() != null) {
300+
boolean following = plugin.getFollowModule()
301+
.isFollowing(profile.uuid, event.getUser().getId());
302+
var rows = new java.util.ArrayList<net.dv8tion.jda.api.interactions.components.LayoutComponent>();
303+
rows.add(net.dv8tion.jda.api.interactions.components.ActionRow.of(
304+
following
305+
? plugin.getFollowModule().buildUnfollowButton(profile.uuid)
306+
: plugin.getFollowModule().buildFollowButton(profile.uuid)));
307+
event.getHook().sendMessageEmbeds(embed.build())
308+
.addComponents(rows).queue();
309+
} else {
310+
event.getHook().sendMessageEmbeds(embed.build()).queue();
311+
}
312+
});
313+
}
314+
315+
private OfflinePlayer resolveProfileTarget(String queryName, String requesterDiscordId) {
316+
if (queryName != null && !queryName.isEmpty()) {
317+
return PlayerProfileBuilder.findOfflineByName(queryName);
318+
}
319+
// No name → look up the requester's linked MC account.
320+
if (plugin.getLinkModule() == null) {
321+
return null;
322+
}
323+
UUID mcUuid = plugin.getLinkModule().getPlayerUUID(requesterDiscordId);
324+
if (mcUuid == null) {
325+
return null;
326+
}
327+
return Bukkit.getOfflinePlayer(mcUuid);
328+
}
329+
330+
private void handleSeen(SlashCommandInteractionEvent event) {
331+
event.deferReply().queue();
332+
String queryName = event.getOption("player").getAsString();
333+
plugin.getPlatformAdapter().runAsync(() -> {
334+
OfflinePlayer target = PlayerProfileBuilder.findOfflineByName(queryName);
335+
if (target == null) {
336+
event.getHook().sendMessage("No player named **" + queryName
337+
+ "** has joined this server before.")
338+
.setEphemeral(true).queue();
339+
return;
340+
}
341+
UUID uuid = target.getUniqueId();
342+
String name = target.getName() != null ? target.getName() : "Unknown";
343+
long lastSeen = plugin.getStorageManager().getLastSeen(uuid);
344+
long playtimeSec = plugin.getLeaderboardModule() != null
345+
? plugin.getLeaderboardModule().getStat(uuid, "playtime")
346+
: 0L;
347+
348+
EmbedBuilder embed = new EmbedBuilder()
349+
.setAuthor(name + " · Last seen",
350+
"https://namemc.com/profile/" + uuid,
351+
HeadUtil.crafatar(uuid))
352+
.setColor(ColorUtil.parseHex("#3498DB"))
353+
.setTimestamp(Instant.now());
354+
355+
if (target.isOnline()) {
356+
embed.setDescription(":green_circle: **" + name + "** is online right now.");
357+
} else if (lastSeen > 0) {
358+
embed.setDescription("Last seen: <t:" + (lastSeen / 1000L) + ":R>.")
359+
.addField("Last seen", "<t:" + (lastSeen / 1000L) + ":F>", false)
360+
.addField("Total playtime",
361+
PlayerProfileBuilder.formatDuration(playtimeSec), true)
362+
.addField("Sessions", String.valueOf(
363+
plugin.getStorageManager().getSessions(uuid)), true);
364+
} else {
365+
embed.setDescription("No activity recorded for **" + name + "** yet.");
366+
}
367+
event.getHook().sendMessageEmbeds(embed.build()).queue();
368+
});
369+
}
370+
371+
private void handleFollowing(SlashCommandInteractionEvent event) {
372+
event.deferReply().queue();
373+
String discordId = event.getUser().getId();
374+
plugin.getPlatformAdapter().runAsync(() -> {
375+
if (plugin.getFollowModule() == null) {
376+
event.getHook().sendMessage("The follow feature is disabled.")
377+
.setEphemeral(true).queue();
378+
return;
379+
}
380+
java.util.Set<UUID> followed = plugin.getFollowModule().getFollowedPlayers(discordId);
381+
if (followed.isEmpty()) {
382+
event.getHook().sendMessage(
383+
"You aren't following any Minecraft players. "
384+
+ "Use `/profile player:<name>` to find someone and hit **Follow**.")
385+
.setEphemeral(true).queue();
386+
return;
387+
}
388+
StringBuilder sb = new StringBuilder();
389+
for (UUID uuid : followed) {
390+
OfflinePlayer op = Bukkit.getOfflinePlayer(uuid);
391+
String name = op.getName() != null ? op.getName() : uuid.toString();
392+
sb.append(":small_blue_diamond: **").append(name).append("**")
393+
.append(" (`").append(uuid).append("`)\n");
394+
}
395+
EmbedBuilder embed = new EmbedBuilder()
396+
.setTitle("Following " + followed.size() + " player"
397+
+ (followed.size() == 1 ? "" : "s"))
398+
.setDescription(sb.toString())
399+
.setColor(ColorUtil.parseHex("#9B59B6"))
400+
.setTimestamp(Instant.now());
401+
event.getHook().sendMessageEmbeds(embed.build()).queue();
402+
});
403+
}
404+
405+
@Override
406+
public void onButtonInteraction(ButtonInteractionEvent event) {
407+
String id = event.getComponentId();
408+
if (id == null) {
409+
return;
410+
}
411+
if (id.startsWith(dev.demonz.zdiscord.modules.FollowModule.FOLLOW_BUTTON_ID)
412+
|| id.startsWith(dev.demonz.zdiscord.modules.FollowModule.UNFOLLOW_BUTTON_ID)) {
413+
if (plugin.getFollowModule() != null) {
414+
plugin.getFollowModule().handleFollowButton(event);
415+
} else {
416+
event.reply("The follow feature is disabled.").setEphemeral(true).queue();
417+
}
418+
}
419+
}
420+
421+
private void handleConfess(SlashCommandInteractionEvent event) {
422+
String channelId = plugin.getConfigManager()
423+
.getString("channels.confessions", "").trim();
424+
if (channelId.isEmpty() || channelId.startsWith("YOUR_")) {
425+
event.reply(":lock: Confessions are disabled on this server "
426+
+ "(no `channels.confessions` is configured).")
427+
.setEphemeral(true).queue();
428+
return;
429+
}
430+
var channel = plugin.getBotManager().getJda()
431+
.getTextChannelById(channelId);
432+
if (channel == null) {
433+
event.reply(":lock: Confessions are disabled on this server "
434+
+ "(the configured channel could not be found).")
435+
.setEphemeral(true).queue();
436+
return;
437+
}
438+
String message = event.getOption("message").getAsString();
439+
if (message.length() > 1500) {
440+
event.reply(":lock: Your confession is too long (max 1500 characters).")
441+
.setEphemeral(true).queue();
442+
return;
443+
}
444+
// Anonymise: a deterministic but unguessable handle based
445+
// on the user id (so the same person keeps the same
446+
// handle, which is nice for ongoing confessions, but the
447+
// handle itself reveals nothing).
448+
String handle = "Confessor #" + Math.abs(event.getUser().getId().hashCode() % 10000);
449+
450+
EmbedBuilder embed = new EmbedBuilder()
451+
.setAuthor(":love_letter: A new confession", null, null)
452+
.setDescription(ColorUtil.toDiscordMarkdown(message))
453+
.setColor(0x9B59B6)
454+
.setFooter(handle + " · posted at", null)
455+
.setTimestamp(Instant.now());
456+
457+
channel.sendMessageEmbeds(embed.build()).queue(
458+
success -> event.reply(":white_check_mark: Your confession was posted anonymously.")
459+
.setEphemeral(true).queue(),
460+
error -> event.reply(":x: Failed to post your confession: "
461+
+ error.getMessage()).setEphemeral(true).queue());
462+
}
245463
}

0 commit comments

Comments
 (0)