Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;

@Subscribe
Expand All @@ -76,6 +81,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 @@ -94,6 +119,7 @@ public ZanderVelocityMain(
this.proxy = proxy;
this.logger = logger;
this.commandManager = commandManager;
this.dataDirectory = dataDirectory;

// Create configuration file
try {
Expand All @@ -114,5 +140,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