Skip to content
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# zander

Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander)
Documentation: [https://modularsoft.org/docs/products/zander](https://modularsoft.org/docs/products/zander)

Product docs:
- [Private messaging (Zander Velocity)](docs/private-messaging.md)
85 changes: 85 additions & 0 deletions docs/private-messaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Private Messaging (Zander Velocity)

Zander Velocity provides a private messaging system with direct messages, reply shortcuts, ignore lists, and per-player message toggles.

## Commands

### /message

Aliases: `m`, `msg`, `w`, `whisper`, `tell`, `t`
Permission: `zander.command.message`

Usage:

```
/msg <player> <message...>
/tell <player> <message...>
```

Behavior:

- Sends a private message from the sender to the target player.
- Blocks messaging yourself.
- Rejects offline or unknown targets.
- Respects the target's `/togglemessages` preference and ignore list.
- Updates the reply mapping for both players when a message is sent.

Sender sees: `To <target>: <message>`
Target sees: `From <sender>: <message>`

### /reply

Alias: `r`
Permission: `zander.command.reply`

Usage:

```
/r <message...>
```

Behavior:

- Sends a private message to the last player you messaged (or who messaged you).
- If no target exists, returns `No one to reply to.`
- Re-checks all message rules (online, toggle, ignore).
- Clears the reply target when the target is offline.

### /ignore

Aliases: `ignores`
Base permission: `zander.command.ignore`

Subcommands:

```
/ignore add <player> # permission: zander.command.ignore.add
/ignore remove <player> # permission: zander.command.ignore.remove
/ignore list # permission: zander.command.ignore.list
```

Behavior:

- Ignore lists store UUIDs and persist across restarts.
- Prevents ignoring yourself and duplicate entries.
- `/ignore list` shows up to 10 entries and includes a summary if more exist.
- If player B ignores player A, player A cannot message or reply to player B.

### /togglemessages

Alias: `toggle-messages`
Permission: `zander.command.togglemessages`

Behavior:

- Toggles whether you can receive private messages.
- When disabled, inbound `/message` and `/reply` attempts are blocked.
- Outbound messaging remains allowed by default.

## Data Storage

- Persistent:
- `messagesDisabled[uuid]` (boolean)
- `ignoreList[uuid]` (set of UUIDs)
- In-memory (resets on restart):
- `lastConversation[uuid]` (UUID)
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.modularsoft.zander.velocity.events.session.UserOnSwitch;
import org.modularsoft.zander.velocity.util.announcement.TipChatter;
import org.modularsoft.zander.velocity.util.api.Heartbeat;
import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService;
import org.slf4j.Logger;

import java.io.File;
Expand All @@ -52,6 +53,10 @@ public class ZanderVelocityMain {
@Getter
private static YamlDocument config;
@Getter
private static Path dataDirectory;
@Getter
private static PrivateMessageService privateMessageService;
@Getter
private final CommandManager commandManager;
@Getter
private static ZanderVelocityMain instance;
Expand All @@ -78,6 +83,26 @@ public void onProxyInitialization(ProxyInitializeEvent event) {
commandManager.register(commandManager.metaBuilder("report").build(), new report());
commandManager.register(commandManager.metaBuilder("clearchat").build(), new clearchat());
commandManager.register(commandManager.metaBuilder("freezechat").build(), new freezechat());
message messageCommand = new message();
commandManager.register(commandManager.metaBuilder("message").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("m").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("msg").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("w").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("whisper").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("tell").build(), messageCommand);
commandManager.register(commandManager.metaBuilder("t").build(), messageCommand);

reply replyCommand = new reply();
commandManager.register(commandManager.metaBuilder("reply").build(), replyCommand);
commandManager.register(commandManager.metaBuilder("r").build(), replyCommand);

ignore ignoreCommand = new ignore();
commandManager.register(commandManager.metaBuilder("ignore").build(), ignoreCommand);
commandManager.register(commandManager.metaBuilder("ignores").build(), ignoreCommand);

togglemessages toggleMessagesCommand = new togglemessages();
commandManager.register(commandManager.metaBuilder("togglemessages").build(), toggleMessagesCommand);
commandManager.register(commandManager.metaBuilder("toggle-messages").build(), toggleMessagesCommand);

// Start the Heartbeat task
Heartbeat.startHeartbeatTask();
Expand All @@ -96,6 +121,7 @@ public ZanderVelocityMain(
this.proxy = proxy;
this.logger = logger;
this.commandManager = commandManager;
this.dataDirectory = dataDirectory;
instance = this;

// Create configuration file
Expand All @@ -117,5 +143,6 @@ public ZanderVelocityMain(
}

logger.info("Zander Proxy has started.");
privateMessageService = new PrivateMessageService(dataDirectory, logger);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package org.modularsoft.zander.velocity.commands;

import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import lombok.NonNull;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.modularsoft.zander.velocity.ZanderVelocityMain;
import org.modularsoft.zander.velocity.util.messaging.PrivateMessageService;
import org.modularsoft.zander.velocity.util.messaging.VanishStatusResolver;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.Set;
import java.util.UUID;

public class ignore implements SimpleCommand {

private static final String BASE_PERMISSION = "zander.command.ignore";
private static final String ADD_PERMISSION = "zander.command.ignore.add";
private static final String REMOVE_PERMISSION = "zander.command.ignore.remove";
private static final String LIST_PERMISSION = "zander.command.ignore.list";
private static final int MAX_LIST_SIZE = 10;
private final PrivateMessageService messageService = ZanderVelocityMain.getPrivateMessageService();

@Override
public void execute(@NonNull Invocation invocation) {
CommandSource source = invocation.source();
if (!(source instanceof Player player)) {
source.sendMessage(Component.text("This command can only be used by players.").color(NamedTextColor.RED));
return;
}
if (!player.hasPermission(BASE_PERMISSION)) {
player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED));
return;
}
String[] args = invocation.arguments();
if (args.length == 0) {
player.sendMessage(Component.text("Usage: /ignore <add|remove|list> [player]").color(NamedTextColor.RED));
return;
}

String subcommand = args[0].toLowerCase();
switch (subcommand) {
case "add" -> handleAdd(player, args);
case "remove" -> handleRemove(player, args);
case "list" -> handleList(player);
default -> player.sendMessage(Component.text("Usage: /ignore <add|remove|list> [player]").color(NamedTextColor.RED));
}
}

@Override
public List<String> suggest(@NonNull Invocation invocation) {
String[] args = invocation.arguments();
if (args.length <= 1) {
String prefix = args.length == 1 ? args[0].toLowerCase() : "";
return Stream.of("add", "remove", "list")
.filter(option -> option.startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
if (args.length == 2 && ("add".equalsIgnoreCase(args[0]) || "remove".equalsIgnoreCase(args[0]))) {
String prefix = args[1].toLowerCase();
return ZanderVelocityMain.getProxy().getAllPlayers().stream()
.filter(player -> !VanishStatusResolver.isVanished(player))
.map(Player::getUsername)
.filter(name -> name.toLowerCase().startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
return List.of();
}

private void handleAdd(Player player, String[] args) {
if (!player.hasPermission(ADD_PERMISSION)) {
player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED));
return;
}
if (args.length < 2) {
player.sendMessage(Component.text("Usage: /ignore add <player>").color(NamedTextColor.RED));
return;
}
String targetName = args[1];
if (targetName.equalsIgnoreCase(player.getUsername())) {
player.sendMessage(Component.text("You cannot ignore yourself.").color(NamedTextColor.RED));
return;
}
Optional<UUID> targetUuid = messageService.resolveUuid(targetName, ZanderVelocityMain.getProxy());
if (targetUuid.isEmpty()) {
player.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED));
return;
}
if (targetUuid.get().equals(player.getUniqueId())) {
player.sendMessage(Component.text("You cannot ignore yourself.").color(NamedTextColor.RED));
return;
}
boolean added = messageService.addIgnore(player.getUniqueId(), targetUuid.get());
String displayName = messageService.getCachedName(targetUuid.get()).orElse(targetName);
if (!added) {
player.sendMessage(Component.text("You are already ignoring " + displayName + ".").color(NamedTextColor.YELLOW));
return;
}
player.sendMessage(Component.text("You are now ignoring " + displayName + ".").color(NamedTextColor.GREEN));
}

private void handleRemove(Player player, String[] args) {
if (!player.hasPermission(REMOVE_PERMISSION)) {
player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED));
return;
}
if (args.length < 2) {
player.sendMessage(Component.text("Usage: /ignore remove <player>").color(NamedTextColor.RED));
return;
}
String targetName = args[1];
Optional<UUID> targetUuid = messageService.resolveUuid(targetName, ZanderVelocityMain.getProxy());
if (targetUuid.isEmpty()) {
player.sendMessage(Component.text("Player not found.").color(NamedTextColor.RED));
return;
}
boolean removed = messageService.removeIgnore(player.getUniqueId(), targetUuid.get());
String displayName = messageService.getCachedName(targetUuid.get()).orElse(targetName);
if (!removed) {
player.sendMessage(Component.text(displayName + " is not on your ignore list.").color(NamedTextColor.YELLOW));
return;
}
player.sendMessage(Component.text("You are no longer ignoring " + displayName + ".").color(NamedTextColor.GREEN));
}

private void handleList(Player player) {
if (!player.hasPermission(LIST_PERMISSION)) {
player.sendMessage(Component.text("You do not have permission to use this command.").color(NamedTextColor.RED));
return;
}
Set<UUID> ignores = messageService.getIgnoreList(player.getUniqueId());
if (ignores.isEmpty()) {
player.sendMessage(Component.text("You are not ignoring anyone.").color(NamedTextColor.YELLOW));
return;
}
List<String> names = new ArrayList<>();
for (UUID uuid : ignores) {
names.add(messageService.getCachedName(uuid).orElse(uuid.toString()));
}
names.sort(Comparator.comparing(String::toLowerCase));
int total = names.size();
List<String> displayNames = names.subList(0, Math.min(MAX_LIST_SIZE, total));
StringBuilder builder = new StringBuilder();
builder.append("Ignored players (").append(total).append("): ")
.append(String.join(", ", displayNames));
if (total > MAX_LIST_SIZE) {
builder.append(" and ").append(total - MAX_LIST_SIZE).append(" more...");
}
player.sendMessage(Component.text(builder.toString()).color(NamedTextColor.GRAY));
}
}
Loading