From d7a134a2f5bafd20cd1acc37f0b102492735473b Mon Sep 17 00:00:00 2001 From: Macaron27 Date: Sat, 3 Oct 2026 12:53:22 +0200 Subject: [PATCH] Add private messages, last-seen display and server-based presence (0.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private messages: /msg (/tell, /w, /whisper) and /r (/reply), friends only, network-wide. A cancellable FriendMessageEvent (Bukkit, Velocity, BungeeCord) fires first: cancel it to own the messaging layer, or rewrite the text. Messages go straight to Redis (not behind the SQL queue) and are never stored. Player text is never parsed; section signs and control characters are stripped. private-messages.enabled: false leaves the commands to another plugin. Last seen: LastSeen (API) words times ("Last seen 17 minutes ago"); /f list shows "● Bob — Playing BedWars Solo" / "● Bob — Last seen 3 days ago", and /msg to an offline friend says when they were last seen. Presence: presence.rules map backend server-name regexes to a generic PlayerActivity (game, mode). Evaluated on join and server switch, carried by the existing Presence / Event.Updated through Redis (published only when it changed), exposed as FriendsAPI.getActivity. Bad rules are skipped with a warning. No party logic: that belongs to Parties & Chill. Upgrades: no schema change; missing config.yml sections are appended with their comments; older proxies ignore the new Presence field. New e2e scenarios upgrade-paper / upgrade-velocity run the previous release, then this build on the same data. Co-Authored-By: Claude Opus 5.5 --- README.md | 50 +++++++++- api/README.md | 30 +++++- .../main/java/com/friends/api/FriendsAPI.java | 10 ++ .../main/java/com/friends/api/LastSeen.java | 52 +++++++++++ .../java/com/friends/api/PlayerActivity.java | 69 ++++++++++++++ api/src/main/java/com/friends/api/Result.java | 4 +- .../api/bukkit/FriendMessageEvent.java | 92 ++++++++++++++++++ .../api/bungee/FriendMessageEvent.java | 71 ++++++++++++++ .../api/velocity/FriendMessageEvent.java | 71 ++++++++++++++ build.gradle.kts | 2 +- .../java/com/friends/bungee/BungeeHooks.java | 7 ++ .../com/friends/bungee/FriendsBungee.java | 30 ++++-- .../java/com/friends/core/Activities.java | 72 ++++++++++++++ .../java/com/friends/core/ConfigUpgrade.java | 72 ++++++++++++++ .../src/main/java/com/friends/core/Event.java | 3 + .../java/com/friends/core/FriendCommand.java | 24 ++++- .../main/java/com/friends/core/Friends.java | 93 +++++++++++++++++-- .../java/com/friends/core/FriendsApi.java | 6 ++ .../java/com/friends/core/FriendsRuntime.java | 8 +- .../src/main/java/com/friends/core/Hooks.java | 8 ++ .../main/java/com/friends/core/Messages.java | 91 +++++++++++++----- .../main/java/com/friends/core/Presence.java | 15 ++- .../java/com/friends/core/RedisNetwork.java | 18 +++- .../main/java/com/friends/core/Settings.java | 18 +++- core/src/main/resources/config.yml | 24 ++++- .../java/com/friends/paper/BukkitHooks.java | 7 ++ .../java/com/friends/paper/PaperFriends.java | 73 ++++++++++++++- tools/e2e/README.md | 5 +- tools/e2e/run.py | 84 +++++++++++++++-- .../com/friends/velocity/FriendsVelocity.java | 28 ++++-- .../com/friends/velocity/VelocityHooks.java | 7 ++ 31 files changed, 1064 insertions(+), 80 deletions(-) create mode 100644 api/src/main/java/com/friends/api/LastSeen.java create mode 100644 api/src/main/java/com/friends/api/PlayerActivity.java create mode 100644 api/src/main/java/com/friends/api/bukkit/FriendMessageEvent.java create mode 100644 api/src/main/java/com/friends/api/bungee/FriendMessageEvent.java create mode 100644 api/src/main/java/com/friends/api/velocity/FriendMessageEvent.java create mode 100644 core/src/main/java/com/friends/core/Activities.java create mode 100644 core/src/main/java/com/friends/core/ConfigUpgrade.java diff --git a/README.md b/README.md index cde11d9..c43e890 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,55 @@ SQLite driver: Paper 1.12.2's has no Apple Silicon build (Linux x86_64/ARM are f | `/f notifications` | Toggle "Friend > X joined./left." messages. | | `/status [online\|away\|busy\|offline]` | Status shown to friends; `offline` = appear offline (no join/leave messages). | +Private messages (friends only, network-wide; turn off with `private-messages.enabled: false`): + +| Command | What it does | +|---|---| +| `/msg ` (also `/tell`, `/w`, `/whisper`) | Message a friend who is online and not appearing offline. Offline: "Bob is offline. (Last seen 3 days ago)". | +| `/r ` (also `/reply`) | Answer whoever you last messaged or heard from. Clicking a message line also starts a reply. | + +`/f list` shows each friend's presence: + +``` +● Player1 — Playing BedWars Solo (green: online; yellow: away; red: busy, with "Away · " / "Busy · ") +● Player2 — In lobby-1 (no presence rule matches the server) +● Player3 — Last seen 17 minutes ago (offline, or appearing offline) +``` + Permission `friends.use`: on by default on Paper; on Velocity allowed unless explicitly false; BungeeCord has no permission check (no permission defaults there). ## Config (`plugins/Friends/config.yml`, `plugins/friends/` on Velocity) The same file on every platform: `storage.type` `sqlite` or `mysql` (MySQL/MariaDB), `request-expiry-minutes` -(default 5), `max-friends` (default 5000), and the `redis` section (proxies only; ignored on Paper). +(default 5), `max-friends` (default 5000), the `redis` section (proxies only; ignored on Paper), +`private-messages.enabled` (default true) and `presence.rules` (proxies only): + +```yaml +presence: + rules: # first match wins; each pattern is a regex matching the whole server name, ignoring case + - pattern: "BW.1S.*" + game: "BedWars" + mode: "Solo" + - pattern: "BW.2S.*" + game: "BedWars" + mode: "Doubles" + - pattern: "SW.*" + game: "SkyWars" # mode is optional: "Playing SkyWars" +``` + +A rule with an invalid regex or without a game is skipped with a warning at startup; Friends keeps running. + +## Upgrading + +Replace the jar and restart: no database migration. Sections a new version adds (in 0.6.0: `private-messages` and +`presence`) are appended to your `config.yml` with their comments at startup; nothing you wrote is changed. + +- **0.6.0 adds `/msg`, `/tell`, `/w`, `/whisper`, `/r` and `/reply`** (friends only). If another plugin provides them, + set `private-messages.enabled: false`. On Paper, a plugin that declares `/msg` in its plugin.yml (e.g. Essentials) + keeps it and Friends' becomes `/friends:msg` (logged); on the proxies Friends replaces an existing `/msg` (logged). +- **Multi-proxy:** upgrade every proxy together. Older proxies ignore activities (they keep working), but a private + message to a player on a proxy that isn't upgraded yet is lost (that proxy logs "ignoring unknown event type"). ## Multiple proxies @@ -76,7 +118,8 @@ Use the plugin either on the proxies or on standalone Paper servers, not both. ## API for other plugins `FriendsAPI` is the same on Paper, Velocity and BungeeCord: friend lists, status, current server and pending -requests, actions (request, accept, deny, remove, best friend, nickname, status) and six events per platform. See +requests, activity, actions (request, accept, deny, remove, best friend, nickname, status) and seven events per +platform (including a cancellable `FriendMessageEvent` to plug in your own chat system). See [`api/README.md`](api/README.md) for setup, examples and the threading rules. ## How it works @@ -94,6 +137,9 @@ requests, actions (request, accept, deny, remove, best friend, nickname, status) bootstrap, where other plugins' class loaders can see it. It skips SQLite (every Paper bundles its own; the SQL stays portable to SQLite 3.7.2, Paper 1.8.8's) and Redis, so it is 3.4 MB, and tells Paper 1.20.5+ not to remap it. +- Presence (online, server, status, activity) lives in memory and, with several proxies, in the Redis `:online` + hash: a server switch evaluates `presence.rules` once and publishes one update only if something changed. Private + messages are one pub/sub event, published straight away (not behind the SQL queue) and never stored. - Tables are `friends_players` and `friends_friendships`. Data from the old Bungee/Paper tables (`players`, `friends`, `requests`, `settings`) is not migrated. diff --git a/api/README.md b/api/README.md index de2e483..37baca5 100644 --- a/api/README.md +++ b/api/README.md @@ -1,7 +1,7 @@ # Friends API Lets other plugins read friend lists, presence and requests, act on behalf of players and react to friend events. -The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has six events +The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has seven events (`com.friends.api.bukkit`, `.velocity`, `.bungee`). The API is Java 8 bytecode with no dependencies of its own, so any plugin can compile against it. Every public type and method has Javadoc (`./gradlew :api:javadoc`). @@ -23,7 +23,7 @@ Or install it in your local Maven repository from this repository: ```kotlin repositories { mavenLocal() } -dependencies { compileOnly("com.friends:friends-api:0.5.0") } +dependencies { compileOnly("com.friends:friends-api:0.6.0") } ``` Then make your plugin load after Friends: @@ -52,11 +52,22 @@ List list = friends.getFriends(player); // empty unless loaded boolean pals = friends.areFriends(alice, bob); // at least one of them must be loaded Optional status = friends.getStatus(player); // online anywhere: ONLINE, AWAY, BUSY, OFFLINE (appears offline) Optional server = friends.getServer(player); // backend server name, proxies only (empty on Paper) +Optional doing = friends.getActivity(player); // from presence.rules, proxies only (see below) List in = friends.getIncomingRequests(player); List out = friends.getOutgoingRequests(player); ``` `Friend` gives `getUniqueId()`, `getName()`, `getSince()`, `isBestFriend()`, `getNickname()` and `getLastSeen()`. +`PlayerActivity` gives `getGame()`, `getMode()` (null if the rule has none) and `describe()` (`"Playing BedWars Solo"`): +the first `presence.rules` pattern matching the player's backend server, synchronised across proxies like the server. + +For menus, scoreboards and other UIs, `LastSeen` words times like Friends' chat does: + +```java +String online = friends.getActivity(id).map(PlayerActivity::describe).orElse("Online"); +String offline = LastSeen.format(friend.getLastSeen()); // "Last seen 17 minutes ago" +String ago = LastSeen.ago(friend.getLastSeen(), Instant.now()); // "17 minutes ago", "3 days ago", "just now" +``` `FriendRequest` gives `getSender()`, `getSenderName()`, `getTarget()`, `getTargetName()` and `getExpiresAt()`. ## 4. Load and act (asynchronous) @@ -88,10 +99,25 @@ gets no chat reply: the `Result` tells you what happened. The other player is st | `FriendRemoveEvent` | yes | before a friendship ends, once per friend (`/f removeall` too) | | `FriendRemovedEvent` | no | after, exactly once | | `FriendStatusChangeEvent` | yes | before `/status` changes | +| `FriendMessageEvent` | yes | before a private message (`/msg`, `/r`) is delivered; `setMessage` rewrites it | Pair events have `getPlayerId()` / `getPlayerName()` (the acting player) and `getTargetId()` / `getTargetName()`. The status event has `getOldStatus()` / `getNewStatus()`. `FriendAddedEvent` also has `getSince()`. +`FriendMessageEvent` (sender = player, recipient = target) has `getMessage()` / `setMessage()`. Cancel it to own the +messaging layer: Friends then delivers nothing, echoes nothing and doesn't change either player's `/r` target, so your +chat plugin can deliver (or block) it its own way, e.g. through your network's chat system: + +```java +@Subscribe // Velocity; on BungeeCord/Paper: @EventHandler and e.setCancelled(true) +public void onMessage(FriendMessageEvent e) { + e.setResult(ResultedEvent.GenericResult.denied()); + myChat.whisper(e.getPlayerId(), e.getTargetId(), e.getMessage()); +} +``` + +The text is plain: Friends never parses colours or formatting in it. + Paper (`com.friends.api.bukkit`, asynchronous Bukkit events): ```java diff --git a/api/src/main/java/com/friends/api/FriendsAPI.java b/api/src/main/java/com/friends/api/FriendsAPI.java index b242869..d86f32e 100644 --- a/api/src/main/java/com/friends/api/FriendsAPI.java +++ b/api/src/main/java/com/friends/api/FriendsAPI.java @@ -107,6 +107,16 @@ static Optional find() { */ Optional getServer(UUID player); + /** + * What a player online anywhere on the network is doing, from the first {@code presence.rules} pattern matching + * their backend server's name (proxies only). Synchronised across proxies, like {@link #getServer}. + * + * @param player a player's UUID + * @return e.g. BedWars / Solo; empty on Paper, while connecting, if no rule matches their server, if offline, or + * if they appear offline + */ + Optional getActivity(UUID player); + /** * Pending requests the player received (network-wide). * diff --git a/api/src/main/java/com/friends/api/LastSeen.java b/api/src/main/java/com/friends/api/LastSeen.java new file mode 100644 index 0000000..af59fdf --- /dev/null +++ b/api/src/main/java/com/friends/api/LastSeen.java @@ -0,0 +1,52 @@ +package com.friends.api; + +import java.time.Duration; +import java.time.Instant; + +/** + * Human-readable "last seen" times, as Friends shows them in chat; for menus, scoreboards and other plugins' UIs. + * Example: {@code LastSeen.format(friend.getLastSeen())} gives {@code "Last seen 17 minutes ago"}. + */ +public final class LastSeen { + private static final long[] SIZES = {60, 60, 24, 30, 12}; + private static final String[] UNITS = {"minute", "hour", "day", "month", "year"}; + + private LastSeen() {} + + /** + * How long ago {@code then} was, in its largest whole unit (a month counts 30 days). + * + * @param then a past instant + * @param now the current instant + * @return {@code "just now"} under a minute (or for a future {@code then}), else e.g. {@code "1 minute ago"}, + * {@code "17 minutes ago"}, {@code "3 days ago"}, {@code "2 years ago"} + */ + public static String ago(Instant then, Instant now) { + long n = Math.max(0, Duration.between(then, now).getSeconds()); + if (n < 60) return "just now"; + int unit = -1; + while (unit + 1 < UNITS.length && n >= SIZES[unit + 1]) n /= SIZES[++unit]; + return n + " " + UNITS[unit] + (n == 1 ? "" : "s") + " ago"; + } + + /** + * {@link #ago} as a sentence. + * + * @param then a past instant + * @param now the current instant + * @return e.g. {@code "Last seen 17 minutes ago"} or {@code "Last seen just now"} + */ + public static String format(Instant then, Instant now) { + return "Last seen " + ago(then, now); + } + + /** + * {@link #format(Instant, Instant)} as of now. + * + * @param then a past instant, such as {@link Friend#getLastSeen()} + * @return e.g. {@code "Last seen 3 days ago"} + */ + public static String format(Instant then) { + return format(then, Instant.now()); + } +} diff --git a/api/src/main/java/com/friends/api/PlayerActivity.java b/api/src/main/java/com/friends/api/PlayerActivity.java new file mode 100644 index 0000000..d17405f --- /dev/null +++ b/api/src/main/java/com/friends/api/PlayerActivity.java @@ -0,0 +1,69 @@ +package com.friends.api; + +import java.util.Objects; + +/** + * What a player is doing, derived from the backend server they are on ({@code presence.rules} in Friends' config: a + * server-name pattern maps to a game and a mode). Generic on purpose: Friends knows no game, only these two labels. + * Immutable, like every Friends model: it is shared by every reader and synchronised across proxies. + */ +public final class PlayerActivity { + private final String game; + private final String mode; + + /** + * Created by Friends from its config; plugins have no reason to build one except in tests. + * + * @param game the game, e.g. {@code "BedWars"}; never null + * @param mode the mode, e.g. {@code "Solo"}, or null if the rule has none + */ + public PlayerActivity(String game, String mode) { + this.game = Objects.requireNonNull(game, "game"); + this.mode = mode; + } + + /** + * The game. + * + * @return e.g. {@code "BedWars"}, never null + */ + public String getGame() { + return game; + } + + /** + * The mode within the game. + * + * @return e.g. {@code "Solo"}, or null if none + */ + public String getMode() { + return mode; + } + + /** + * How Friends shows it in chat. + * + * @return e.g. {@code "Playing BedWars Solo"}, or {@code "Playing BedWars"} without a mode + */ + public String describe() { + return mode == null ? "Playing " + game : "Playing " + game + " " + mode; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PlayerActivity)) return false; + PlayerActivity a = (PlayerActivity) o; + return game.equals(a.game) && Objects.equals(mode, a.mode); + } + + @Override + public int hashCode() { + return Objects.hash(game, mode); + } + + @Override + public String toString() { + return "PlayerActivity{game=" + game + ", mode=" + mode + "}"; + } +} diff --git a/api/src/main/java/com/friends/api/Result.java b/api/src/main/java/com/friends/api/Result.java index 4ae2e84..1511eaa 100644 --- a/api/src/main/java/com/friends/api/Result.java +++ b/api/src/main/java/com/friends/api/Result.java @@ -29,7 +29,9 @@ public enum Result { /** A plugin cancelled the action's event. */ CANCELLED, /** A database error (Friends logs it). */ - ERROR; + ERROR, + /** The target is offline, or appearing offline (private messages). Last, so earlier ordinals never move. */ + NOT_ONLINE; /** * Whether the action happened. diff --git a/api/src/main/java/com/friends/api/bukkit/FriendMessageEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendMessageEvent.java new file mode 100644 index 0000000..3357f53 --- /dev/null +++ b/api/src/main/java/com/friends/api/bukkit/FriendMessageEvent.java @@ -0,0 +1,92 @@ +package com.friends.api.bukkit; + +import java.util.Objects; +import java.util.UUID; + +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; + +/** + * {@code player} is about to send {@code target}, one of their friends, a private message ({@code /msg} or + * {@code /r}). Fired once, before anything is delivered. + * + *

Cancel it to take over: Friends then delivers nothing, echoes nothing to the sender and doesn't update either + * player's {@code /r} target, so a chat plugin can handle the messaging layer itself (or a moderation plugin can + * block it, explaining why). Or change the text with {@link #setMessage} (e.g. a chat filter). + */ +public final class FriendMessageEvent extends FriendEvent implements Cancellable { + private static final HandlerList HANDLERS = new HandlerList(); + private String message; + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the sender + * @param playerName their name + * @param targetId the recipient + * @param targetName their name + * @param message what the sender typed + */ + public FriendMessageEvent(UUID playerId, String playerName, UUID targetId, String targetName, String message) { + super(playerId, playerName, targetId, targetName); + this.message = message; + } + + /** + * The text to deliver (plain text: never parsed for colours or formatting). + * + * @return never null + */ + public String getMessage() { + return message; + } + + /** + * Replaces the text to deliver. + * + * @param message the new text (plain, never parsed); not null + */ + public void setMessage(String message) { + this.message = Objects.requireNonNull(message, "message"); + } + + /** + * Whether a plugin cancelled the message. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the message: Friends then does nothing more with it. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } + + /** + * Bukkit's handler list of this event. + * + * @return the handlers + */ + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + /** + * Bukkit's handler list of this event (looked up by Bukkit when registering listeners). + * + * @return the handlers + */ + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/api/src/main/java/com/friends/api/bungee/FriendMessageEvent.java b/api/src/main/java/com/friends/api/bungee/FriendMessageEvent.java new file mode 100644 index 0000000..2b5dcba --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/FriendMessageEvent.java @@ -0,0 +1,71 @@ +package com.friends.api.bungee; + +import java.util.Objects; +import java.util.UUID; + +import net.md_5.bungee.api.plugin.Cancellable; + +/** + * {@code player} is about to send {@code target}, one of their friends, a private message ({@code /msg} or + * {@code /r}). Fired once network-wide, on the sender's proxy, before anything is delivered. + * + *

Cancel it to take over: Friends then delivers nothing, echoes nothing to the sender and doesn't update either + * player's {@code /r} target, so a chat plugin can handle the messaging layer itself (or a moderation plugin can + * block it, explaining why). Or change the text with {@link #setMessage} (e.g. a chat filter). + */ +public final class FriendMessageEvent extends FriendEvent implements Cancellable { + private String message; + private boolean cancelled; + + /** + * Created by Friends. + * + * @param playerId the sender + * @param playerName their name + * @param targetId the recipient + * @param targetName their name + * @param message what the sender typed + */ + public FriendMessageEvent(UUID playerId, String playerName, UUID targetId, String targetName, String message) { + super(playerId, playerName, targetId, targetName); + this.message = message; + } + + /** + * The text to deliver (plain text: never parsed for colours or formatting). + * + * @return never null + */ + public String getMessage() { + return message; + } + + /** + * Replaces the text to deliver. + * + * @param message the new text (plain, never parsed); not null + */ + public void setMessage(String message) { + this.message = Objects.requireNonNull(message, "message"); + } + + /** + * Whether a plugin cancelled the message. + * + * @return true if cancelled + */ + @Override + public boolean isCancelled() { + return cancelled; + } + + /** + * Cancels (or un-cancels) the message: Friends then does nothing more with it. + * + * @param cancel true to cancel + */ + @Override + public void setCancelled(boolean cancel) { + cancelled = cancel; + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendMessageEvent.java b/api/src/main/java/com/friends/api/velocity/FriendMessageEvent.java new file mode 100644 index 0000000..c63cc59 --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/FriendMessageEvent.java @@ -0,0 +1,71 @@ +package com.friends.api.velocity; + +import java.util.Objects; +import java.util.UUID; + +import com.velocitypowered.api.event.ResultedEvent; + +/** + * {@code player} is about to send {@code target}, one of their friends, a private message ({@code /msg} or + * {@code /r}). Fired once network-wide, on the sender's proxy, before anything is delivered. + * + *

Deny it to take over: Friends then delivers nothing, echoes nothing to the sender and doesn't update either + * player's {@code /r} target, so a chat plugin can handle the messaging layer itself (or a moderation plugin can + * block it, explaining why). Or change the text with {@link #setMessage} (e.g. a chat filter). + */ +public final class FriendMessageEvent extends FriendEvent implements ResultedEvent { + private String message; + private GenericResult result = GenericResult.allowed(); + + /** + * Created by Friends. + * + * @param playerId the sender + * @param playerName their name + * @param targetId the recipient + * @param targetName their name + * @param message what the sender typed + */ + public FriendMessageEvent(UUID playerId, String playerName, UUID targetId, String targetName, String message) { + super(playerId, playerName, targetId, targetName); + this.message = message; + } + + /** + * The text to deliver (plain text: never parsed for colours or formatting). + * + * @return never null + */ + public String getMessage() { + return message; + } + + /** + * Replaces the text to deliver. + * + * @param message the new text (plain, never parsed); not null + */ + public void setMessage(String message) { + this.message = Objects.requireNonNull(message, "message"); + } + + /** + * Whether the message may go ahead. + * + * @return {@code allowed()} unless a plugin denied it + */ + @Override + public GenericResult getResult() { + return result; + } + + /** + * {@code GenericResult.denied()} cancels the message: Friends then does nothing more with it. + * + * @param result {@code allowed()} or {@code denied()} + */ + @Override + public void setResult(GenericResult result) { + this.result = Objects.requireNonNull(result, "result"); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e4fa671..e7b86d2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { allprojects { group = "com.friends" - version = "0.5.0" + version = "0.6.0" repositories { mavenCentral() diff --git a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java index f8f3fb8..64b2169 100644 --- a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java +++ b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java @@ -6,6 +6,7 @@ import com.friends.api.Status; import com.friends.api.bungee.FriendAddEvent; import com.friends.api.bungee.FriendAddedEvent; +import com.friends.api.bungee.FriendMessageEvent; import com.friends.api.bungee.FriendRemoveEvent; import com.friends.api.bungee.FriendRemovedEvent; import com.friends.api.bungee.FriendRequestSendEvent; @@ -55,6 +56,12 @@ public boolean statusChanging(PlayerRow player, Status from, Status to) { return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); } + @Override + public String messaging(PlayerRow from, PlayerRow to, String message) { + FriendMessageEvent event = new FriendMessageEvent(from.id(), from.name(), to.id(), to.name(), message); + return allowed(event) ? event.getMessage() : null; + } + private boolean allowed(E event) { call.accept(event); // BungeeCord's event bus catches (and logs) listeners' exceptions return !event.isCancelled(); diff --git a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java index 9a8a21b..bb4c541 100644 --- a/bungee/src/main/java/com/friends/bungee/FriendsBungee.java +++ b/bungee/src/main/java/com/friends/bungee/FriendsBungee.java @@ -3,6 +3,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.sql.SQLException; import java.util.List; @@ -14,6 +15,7 @@ import org.slf4j.LoggerFactory; import com.friends.core.ChatSessions; +import com.friends.core.ConfigUpgrade; import com.friends.core.FriendCommand; import com.friends.core.FriendsRuntime; import com.friends.core.LuckPermsPrefix; @@ -49,8 +51,9 @@ public final class FriendsBungee extends Plugin implements Listener { @Override public void onEnable() { Storage storage = null; + Settings settings; try { - Settings settings = Settings.read(source(loadConfig())); + settings = Settings.read(source(loadConfig())); storage = settings.openStorage(getDataFolder().toPath()); Network network = RedisNetwork.open(settings, log); Function prefixes = getProxy().getPluginManager().getPlugin("LuckPerms") != null ? LuckPermsPrefix::of : _ -> null; @@ -72,6 +75,15 @@ public void onEnable() { plugins.registerCommand(this, new Cmd("friend", null, "f", "friends")); plugins.registerCommand(this, new Cmd("fl", "list")); plugins.registerCommand(this, new Cmd("status", "status")); + if (settings.privateMessages()) { + for (var command : plugins.getCommands()) { + if (List.of("msg", "tell", "w", "whisper", "r", "reply").contains(command.getKey())) { + log.warn("Friends: /{} is already registered by another plugin; Friends replaces it (private-messages.enabled: false keeps theirs)", command.getKey()); + } + } + plugins.registerCommand(this, new Cmd("msg", "msg", "tell", "w", "whisper")); + plugins.registerCommand(this, new Cmd("r", "reply", "reply")); + } plugins.registerListener(this, this); for (ProxiedPlayer p : getProxy().getPlayers()) connect(p, () -> {}); // enabled while players are online } @@ -114,24 +126,30 @@ private void leave(ProxiedPlayer player) { private Configuration loadConfig() throws IOException { File file = new File(getDataFolder(), "config.yml"); + String bundled; + try (InputStream in = getResourceAsStream("config.yml")) { + bundled = new String(Objects.requireNonNull(in, "config.yml missing from the plugin jar").readAllBytes(), StandardCharsets.UTF_8); + } if (!file.exists()) { Files.createDirectories(getDataFolder().toPath()); - try (InputStream in = getResourceAsStream("config.yml")) { - Files.copy(Objects.requireNonNull(in, "config.yml missing from the plugin jar"), file.toPath()); - } + Files.writeString(file.toPath(), bundled); + } else { + List added = ConfigUpgrade.addMissingSections(file.toPath(), bundled); + if (!added.isEmpty()) log.info("Friends: added the new settings {} to config.yml", added); } return ConfigurationProvider.getProvider(YamlConfiguration.class).load(file); } - private static Settings.Source source(Configuration config) { + static Settings.Source source(Configuration config) { return new Settings.Source() { @Override public String string(String path, String def) { return config.getString(path, def); } @Override public int number(String path, int def) { return config.getInt(path, def); } @Override public boolean flag(String path, boolean def) { return config.getBoolean(path, def); } + @Override public List list(String path) { return config.getList(path, List.of()); } }; } - /** /friend, /fl ("list") and /status ("status"). Commands run off BungeeCord's network threads. */ + /** /friend, /fl ("list"), /status ("status"), /msg ("msg") and /r ("reply"). Commands run off BungeeCord's network threads. */ private final class Cmd extends Command implements TabExecutor { private final String sub; diff --git a/core/src/main/java/com/friends/core/Activities.java b/core/src/main/java/com/friends/core/Activities.java new file mode 100644 index 0000000..ae9c6e9 --- /dev/null +++ b/core/src/main/java/com/friends/core/Activities.java @@ -0,0 +1,72 @@ +package com.friends.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +import com.friends.api.PlayerActivity; + +/** + * {@code presence.rules}: backend server-name patterns, each mapped to a {@link PlayerActivity}. A pattern is a regex + * that must match the whole server name, ignoring case; the first matching rule wins. + */ +public final class Activities { + static final Activities NONE = new Activities(List.of()); + + record Rule(Pattern pattern, PlayerActivity activity) {} + + private final List rules; + + private Activities(List rules) { + this.rules = rules; + } + + /** + * Rules as the YAML list of maps ({@code pattern}, {@code game}, optional {@code mode}). A rule without a valid + * pattern or a game is skipped and reported to {@code warn}: a typo in a cosmetic setting never disables Friends. + */ + static Activities parse(List> rules, Consumer warn) { + List out = new ArrayList<>(); + for (int i = 0; i < rules.size(); i++) { + Map rule = rules.get(i); + String pattern = text(rule.get("pattern")); + String game = text(rule.get("game")); + String where = "presence.rules[" + i + "]"; + if (pattern == null || game == null) { + warn.accept(where + " needs both a pattern and a game; skipped"); + continue; + } + try { + out.add(new Rule(Pattern.compile(pattern, Pattern.CASE_INSENSITIVE), new PlayerActivity(game, text(rule.get("mode"))))); + } catch (PatternSyntaxException e) { + warn.accept(where + ": '" + pattern + "' is not a valid regex (" + e.getDescription() + "); skipped"); + } + } + return new Activities(List.copyOf(out)); + } + + /** YAML scalars may be numbers (e.g. {@code mode: 4}); blank means absent. */ + private static String text(Object value) { + return value == null || value.toString().isBlank() ? null : value.toString().strip(); + } + + /** + * The activity on {@code server}, or null if it is null or matches no rule. + * ponytail: tries every rule on each server switch (microseconds for dozens of rules). No per-name cache: cloud + * systems name servers with ever-growing ids (BW-1S-1042...), so it would grow without bound. + */ + public PlayerActivity match(String server) { + if (server == null) return null; + for (Rule r : rules) { + if (r.pattern().matcher(server).matches()) return r.activity(); + } + return null; + } + + public int size() { + return rules.size(); + } +} diff --git a/core/src/main/java/com/friends/core/ConfigUpgrade.java b/core/src/main/java/com/friends/core/ConfigUpgrade.java new file mode 100644 index 0000000..35f12ab --- /dev/null +++ b/core/src/main/java/com/friends/core/ConfigUpgrade.java @@ -0,0 +1,72 @@ +package com.friends.core; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Keeps a server's config.yml current across upgrades: appends, with their comments, the top-level sections that the + * bundled config.yml has and theirs lacks. Nothing they wrote is rewritten or reordered (their comments stay), and + * missing keys inside an existing section are left to the code's defaults. + */ +public final class ConfigUpgrade { + private static final Pattern TOP_LEVEL_KEY = Pattern.compile("^([A-Za-z0-9_-]+):"); + + private ConfigUpgrade() {} + + /** Appends what {@code file} lacks from {@code bundled}; returns the added section names (empty: unchanged). */ + public static List addMissingSections(Path file, String bundled) throws IOException { + String current = Files.readString(file, StandardCharsets.UTF_8); + Set present = current.lines().map(TOP_LEVEL_KEY::matcher).filter(Matcher::find) + .map(m -> m.group(1)).collect(Collectors.toSet()); + StringBuilder added = new StringBuilder(); + List names = new ArrayList<>(); + sections(bundled).forEach((key, block) -> { + if (present.contains(key)) return; + names.add(key); + added.append('\n').append(block); + }); + if (names.isEmpty()) return names; + String text = (current.isEmpty() || current.endsWith("\n") ? "" : "\n") + added; + Files.writeString(file, text, StandardCharsets.UTF_8, StandardOpenOption.APPEND); + return names; + } + + /** Each top-level key's lines, with the column-0 comments just above it. */ + static Map sections(String yaml) { + Map sections = new LinkedHashMap<>(); + StringBuilder pending = new StringBuilder(); // comments and blank lines: they belong to the next key + StringBuilder current = null; + for (String line : yaml.lines().toList()) { + Matcher key = TOP_LEVEL_KEY.matcher(line); + if (key.find()) { + current = new StringBuilder(strip(pending)).append(line).append('\n'); + pending.setLength(0); + sections.put(key.group(1), current); + } else if (line.isBlank() || line.startsWith("#")) { + pending.append(line).append('\n'); + } else if (current != null) { // indented: still the current section + current.append(pending).append(line).append('\n'); + pending.setLength(0); + } + } + Map out = new LinkedHashMap<>(); + sections.forEach((k, v) -> out.put(k, v.toString())); + return out; + } + + /** Drops the blank lines before a section's comment (they separated it from the previous one). */ + private static String strip(CharSequence comment) { + return comment.toString().replaceFirst("^(\\s*\\n)+", ""); + } +} diff --git a/core/src/main/java/com/friends/core/Event.java b/core/src/main/java/com/friends/core/Event.java index 3ced783..958386b 100644 --- a/core/src/main/java/com/friends/core/Event.java +++ b/core/src/main/java/com/friends/core/Event.java @@ -29,6 +29,9 @@ record Befriended(PlayerRow a, PlayerRow b, Instant since) implements Event {} record Unfriended(UUID player, List friends) implements Event {} + /** A private message, already vetted on the sender's proxy: the proxy {@code to} is on delivers it. No state. */ + record PrivateMessage(PlayerRow from, UUID to, String text) implements Event {} + /** A proxy (re)started or stopped heartbeating: forget everyone who was on it. */ record ProxyDown(String proxy) implements Event {} } diff --git a/core/src/main/java/com/friends/core/FriendCommand.java b/core/src/main/java/com/friends/core/FriendCommand.java index 16dfe79..9f5920a 100644 --- a/core/src/main/java/com/friends/core/FriendCommand.java +++ b/core/src/main/java/com/friends/core/FriendCommand.java @@ -13,19 +13,25 @@ import com.friends.core.Friends.Target; import com.friends.core.Platform.Online; -/** Parses {@code /friend ...} arguments. {@code /fl} and {@code /status} arrive as "list"/"status". */ +/** + * Parses {@code /friend ...} arguments. {@code /fl}, {@code /status}, {@code /msg} and {@code /r} arrive as + * "list", "status", "msg" and "reply". + */ public final class FriendCommand { private static final List SUBCOMMANDS = List.of("add", "accept", "deny", "list", "requests", "remove", "best", "nickname", "removeall", "notifications", "status", "help"); private static final int MAX_SUGGESTIONS = 100; private final Friends friends; + private final boolean privateMessages; - public FriendCommand(Friends friends) { + /** {@code privateMessages}: {@code private-messages.enabled} (else "msg" and "reply" are not commands). */ + public FriendCommand(Friends friends, boolean privateMessages) { this.friends = friends; + this.privateMessages = privateMessages; } - /** {@code /fl} and {@code /status} are {@code /friend list} and {@code /friend status}: prepend {@code sub} (if any). */ + /** {@code /fl}, {@code /status}, {@code /msg} and {@code /r} are {@code /friend }: prepend {@code sub} (if any). */ public static String[] withSub(String sub, String[] args) { if (sub == null) return args; String[] out = new String[args.length + 1]; @@ -42,8 +48,11 @@ public static String[] completing(String[] args) { public CompletableFuture execute(Online s, String[] args) { String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT); Target arg = args.length > 1 ? Target.named(args[1]) : null; + if (!privateMessages && (sub.equals("msg") || sub.equals("reply"))) sub = "help"; CompletableFuture result = switch (sub) { - case "help" -> friends.help(s); + case "help" -> friends.help(s, privateMessages); + case "msg" -> args.length < 3 ? usage(s, "/msg ") : friends.message(s, arg, rest(args, 2)); + case "reply" -> args.length < 2 ? usage(s, "/r ") : friends.replyMessage(s, rest(args, 1)); case "add" -> arg == null ? usage(s, "/f add ") : friends.add(s, arg); case "accept" -> arg == null ? usage(s, "/f accept ") : friends.accept(s, arg); case "deny", "decline" -> arg == null ? usage(s, "/f deny ") : friends.deny(s, arg); @@ -57,7 +66,7 @@ public CompletableFuture execute(Online s, String[] args) { case "removeall" -> friends.removeAll(s, args.length > 1 && "confirm".equalsIgnoreCase(args[1])); case "notifications", "notification", "notif" -> friends.toggleNotifications(s); case "status" -> status(s, args.length > 1 ? args[1] : null); - default -> args.length == 1 ? friends.add(s, Target.named(args[0])) : friends.help(s); // "/f Steve" = "/f add Steve" + default -> args.length == 1 ? friends.add(s, Target.named(args[0])) : friends.help(s, privateMessages); // "/f Steve" = "/f add Steve" }; return result.exceptionally(_ -> { s.audience().sendMessage(Messages.error()); @@ -65,6 +74,10 @@ public CompletableFuture execute(Online s, String[] args) { }); } + private static String rest(String[] args, int from) { + return String.join(" ", Arrays.copyOfRange(args, from, args.length)); + } + private CompletableFuture list(Online s, String[] args) { boolean best = args.length > 1 && args[1].equalsIgnoreCase("best"); int pageArg = best ? 2 : 1; @@ -111,6 +124,7 @@ yield filter(friends.onlineNames().stream() } case "accept", "deny", "decline" -> filter(friends.requesterNames(s.id()).stream(), prefix); case "remove", "delete", "best", "nickname", "nick" -> filter(friends.friendNames(s.id()).stream(), prefix); + case "msg" -> privateMessages ? filter(friends.onlineFriendNames(s.id()).stream(), prefix) : List.of(); case "list" -> filter(Stream.of("best"), prefix); case "removeall" -> filter(Stream.of("confirm"), prefix); case "status" -> filter(Stream.of("online", "away", "busy", "offline"), prefix); diff --git a/core/src/main/java/com/friends/core/Friends.java b/core/src/main/java/com/friends/core/Friends.java index b80dbf1..c6a07f6 100644 --- a/core/src/main/java/com/friends/core/Friends.java +++ b/core/src/main/java/com/friends/core/Friends.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; +import com.friends.api.PlayerActivity; import com.friends.api.Result; import com.friends.api.Status; import com.friends.core.Platform.Online; @@ -42,12 +43,16 @@ public final class Friends implements Network.Listener { public static final int PAGE_SIZE = 10; private static final Pattern NAME = Pattern.compile("[\\w.*]{1,16}"); private static final Pattern NICKNAME = Pattern.compile("[\\p{L}\\p{N}_ ]{1,16}"); + // Section signs (legacy colour codes) and control characters: a modified client could send them, and chat + // clients render them inside plain text. The protocol already caps chat at 256 characters. + private static final Pattern UNPRINTABLE = Pattern.compile("[\u00a7\\p{Cntrl}]"); /** Cached state of a player on this proxy. {@code audience} is also the session identity. */ static final class Profile { final Audience audience; final Map friends = new ConcurrentHashMap<>(); volatile boolean notifications; + volatile Target replyTo; // who /r answers: the last private message sent or received (id and name) Profile(Audience audience, Storage.Loaded data) { this.audience = audience; @@ -72,6 +77,7 @@ private interface SqlRun { private final Clock clock; private final Duration requestExpiry; private final int maxFriends; + private final Activities activities; private final Hooks hooks; private final Executor async; // asks plugins (hooks) off the database thread private final Logger log; @@ -97,7 +103,7 @@ private record Cached(CompletableFuture> friends, Instant expires) /** {@code db} runs SQL in order, one task at a time; {@code async} runs whatever asks plugins (any threads). */ public Friends(Storage storage, Platform platform, Network network, Executor db, Executor async, Clock clock, - Duration requestExpiry, int maxFriends, Hooks hooks, Logger log) { + Duration requestExpiry, int maxFriends, Activities activities, Hooks hooks, Logger log) { this.storage = storage; this.platform = platform; this.network = network; @@ -106,6 +112,7 @@ public Friends(Storage storage, Platform platform, Network network, Executor db, this.clock = clock; this.requestExpiry = requestExpiry; this.maxFriends = maxFriends; + this.activities = activities; this.hooks = hooks; this.async = async; this.log = log; @@ -205,6 +212,15 @@ private void applyLocked(Event event) { else markIfLoading(other); } } + case Event.PrivateMessage(PlayerRow from, UUID to, String text) -> { + // Delivered whatever this proxy thinks of the friendship: the sender's proxy checked it, and a + // Befriended published through its SQL queue may still be on its way here. + Profile p = profiles.get(to); + if (p == null) return; // on another proxy (or just left) + p.replyTo = new Target(from.id(), from.name()); + Friend view = p.friends.get(from.id()); + p.audience.sendMessage(Messages.messageFrom(view != null ? Messages.friendName(view) : Messages.name(from), from.name(), text)); + } case Event.ProxyDown(String down) -> { Instant now = clock.instant(); presence.forEach((id, p) -> { @@ -265,7 +281,7 @@ private CompletableFuture install(Online p, Storage.Loaded data) { // Left (or was replaced by a newer session) while loading: don't resurrect a stale profile. if (platform.player(p.id()).filter(o -> o.audience() == p.audience()).isEmpty()) return completedFuture(null); profiles.put(p.id(), new Profile(p.audience(), data)); - emit(new Event.Joined(p.id(), new Presence(p.name(), p.prefix(), proxy, p.server(), data.status()))); + emit(new Event.Joined(p.id(), new Presence(p.name(), p.prefix(), proxy, p.server(), data.status(), activities.match(p.server())))); return completedFuture(null); } } @@ -276,11 +292,18 @@ public void greet(Online p) { if (pending > 0) p.audience().sendMessage(Messages.pending(pending)); } - /** Call after a server switch so friends see the new server. */ + /** + * Call after a server switch: friends network-wide see the new server and the activity {@code presence.rules} + * derive from it (an {@link Event.Updated}, i.e. the Redis presence entry plus one publish). Nothing is published + * if neither changed. + */ public void moved(Online p) { + PlayerActivity activity = activities.match(p.server()); // outside the lock: regexes synchronized (lock) { Presence old = presence.get(p.id()); - if (profiles.containsKey(p.id()) && old != null) emit(new Event.Updated(p.id(), old.withServer(p.server()))); + if (!profiles.containsKey(p.id()) || old == null) return; + Presence now = old.withServer(p.server(), activity); + if (!now.equals(old)) emit(new Event.Updated(p.id(), now)); } } @@ -330,8 +353,9 @@ String label() { } } - public CompletableFuture help(Online s) { - return reply(s, Messages.help(), Result.SUCCESS); + /** {@code privateMessages}: also list {@code /msg} and {@code /r}. */ + public CompletableFuture help(Online s, boolean privateMessages) { + return reply(s, Messages.help(privateMessages), Result.SUCCESS); } public CompletableFuture add(Online s, Target target) { @@ -489,6 +513,51 @@ public CompletableFuture status(Online s, Status status) { } } + /** + * {@code /msg}: a private message to a friend who is visibly online anywhere on the network. Plugins may cancel + * or rewrite it first ({@link Hooks#messaging}); a cancelled message is left entirely to them. + */ + public CompletableFuture message(Online s, Target target, String text) { + return message(s, target, text, "/msg "); + } + + private CompletableFuture message(Online s, Target target, String text, String usage) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + String body = text == null ? "" : UNPRINTABLE.matcher(text).replaceAll("").strip(); + if (body.isEmpty()) return reply(s, Messages.usage(usage), Result.INVALID_ARGUMENT); + Friend f = find(me, target); + if (f == null) return reply(s, Messages.notFriend(target.label()), Result.NOT_FRIENDS); + Presence p = visible(f.id()); + if (p == null) return reply(s, Messages.offline(Messages.friendName(f), f.lastSeen(), clock.instant()), Result.NOT_ONLINE); + String sent = hooks.messaging(row(s), new PlayerRow(f.id(), p.name(), p.prefix(), f.lastSeen()), body); + if (sent == null) return completedFuture(Result.CANCELLED); + // Plugins were asked without the lock: check again that there is still someone to deliver to. + if (!me.friends.containsKey(f.id())) return reply(s, Messages.notFriend(target.label()), Result.NOT_FRIENDS); + if (visible(f.id()) == null) return reply(s, Messages.offline(Messages.friendName(f), f.lastSeen(), clock.instant()), Result.NOT_ONLINE); + me.replyTo = new Target(f.id(), f.name()); + s.audience().sendMessage(Messages.messageTo(Messages.friendName(f), f.name(), sent)); + Event event = new Event.PrivateMessage(row(s), f.id(), sent); + apply(event); // delivers it if the friend is on this proxy + // Straight to the network, not through the SQL queue like emit(): no write to wait for, and chat must not lag. + if (network != Network.LOCAL) network.publish(event); + return completedFuture(Result.SUCCESS); + } + + /** {@code /r}: {@link #message} to whoever this player last messaged or heard from. */ + public CompletableFuture replyMessage(Online s, String text) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + Target to = me.replyTo; + return to == null ? reply(s, Messages.nobodyToReply(), Result.INVALID_ARGUMENT) : message(s, to, text, "/r "); + } + + /** Online anywhere and not appearing offline, else null. */ + private Presence visible(UUID id) { + Presence p = presence.get(id); + return p == null || p.status() == Status.OFFLINE ? null : p; + } + public CompletableFuture list(Online s, int page, boolean bestOnly) { Profile me = profiles.get(s.id()); if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); @@ -513,10 +582,8 @@ public CompletableFuture list(Online s, int page, boolean bestOnly) { : a.friend().name().compareToIgnoreCase(b.friend().name())); private Messages.Entry entry(Friend f) { - Presence p = presence.get(f.id()); - return p == null || p.status() == Status.OFFLINE - ? new Messages.Entry(f, null, null) - : new Messages.Entry(f, p.status(), p.server()); + Presence p = visible(f.id()); + return p == null ? new Messages.Entry(f, null, null, null) : new Messages.Entry(f, p.status(), p.server(), p.activity()); } public CompletableFuture requests(Online s) { @@ -533,6 +600,12 @@ public List friendNames(UUID id) { return p == null ? List.of() : p.friends.values().stream().map(Friend::name).toList(); } + /** Friends a private message would reach right now (tab completion of {@code /msg}). */ + public List onlineFriendNames(UUID id) { + Profile p = profiles.get(id); + return p == null ? List.of() : p.friends.values().stream().filter(f -> visible(f.id()) != null).map(Friend::name).toList(); + } + public List requesterNames(UUID id) { return incoming(id).map(r -> r.from().name()).toList(); } diff --git a/core/src/main/java/com/friends/core/FriendsApi.java b/core/src/main/java/com/friends/core/FriendsApi.java index 47ca593..e3fc411 100644 --- a/core/src/main/java/com/friends/core/FriendsApi.java +++ b/core/src/main/java/com/friends/core/FriendsApi.java @@ -13,6 +13,7 @@ import com.friends.api.Friend; import com.friends.api.FriendRequest; import com.friends.api.FriendsAPI; +import com.friends.api.PlayerActivity; import com.friends.api.Result; import com.friends.api.Status; import com.friends.core.Friends.Target; @@ -57,6 +58,11 @@ public Optional getServer(UUID player) { return presence(player).filter(p -> p.status() != Status.OFFLINE).map(Presence::server); } + @Override + public Optional getActivity(UUID player) { + return presence(player).filter(p -> p.status() != Status.OFFLINE).map(Presence::activity); + } + @Override public List getIncomingRequests(UUID player) { return player == null ? List.of() : friends.incoming(player).map(FriendsApi::view).toList(); diff --git a/core/src/main/java/com/friends/core/FriendsRuntime.java b/core/src/main/java/com/friends/core/FriendsRuntime.java index 2be0d86..dee4492 100644 --- a/core/src/main/java/com/friends/core/FriendsRuntime.java +++ b/core/src/main/java/com/friends/core/FriendsRuntime.java @@ -39,9 +39,12 @@ public FriendsRuntime(Settings settings, Storage storage, Network network, Platf this.storage = storage; this.network = network; this.log = log; + settings.warnings().forEach(w -> log.warn("Friends config: {}", w)); + log.info("Friends: {} presence rule(s); private messages {}", settings.activities().size(), + settings.privateMessages() ? "on /msg and /r" : "off"); this.friends = new Friends(storage, platform, network, db, async, clock, settings.requestExpiry(), - settings.maxFriends(), inOrder(hooks), log); - this.command = new FriendCommand(friends); + settings.maxFriends(), settings.activities(), inOrder(hooks), log); + this.command = new FriendCommand(friends, settings.privateMessages()); this.api = new FriendsApi(friends, platform, async); friends.start(); timer.scheduleWithFixedDelay(() -> { @@ -90,6 +93,7 @@ private Hooks inOrder(Hooks hooks) { @Override public boolean unfriending(PlayerRow player, Friend friend) { return hooks.unfriending(player, friend); } @Override public void unfriended(PlayerRow player, Friend friend) { done.execute(() -> hooks.unfriended(player, friend)); } @Override public boolean statusChanging(PlayerRow player, Status from, Status to) { return hooks.statusChanging(player, from, to); } + @Override public String messaging(PlayerRow from, PlayerRow to, String message) { return hooks.messaging(from, to, message); } }; } } diff --git a/core/src/main/java/com/friends/core/Hooks.java b/core/src/main/java/com/friends/core/Hooks.java index 67c18c3..eb7780c 100644 --- a/core/src/main/java/com/friends/core/Hooks.java +++ b/core/src/main/java/com/friends/core/Hooks.java @@ -33,6 +33,14 @@ default boolean unfriending(PlayerRow player, Friend friend) { default void unfriended(PlayerRow player, Friend friend) {} + /** + * {@code from} is about to send their friend {@code to} a private message. Returns the text to deliver (plugins + * may rewrite it), or null to cancel: then nothing is delivered, echoed or remembered for {@code /r}. + */ + default String messaging(PlayerRow from, PlayerRow to, String message) { + return message; + } + /** False keeps the current status. */ default boolean statusChanging(PlayerRow player, Status from, Status to) { return true; diff --git a/core/src/main/java/com/friends/core/Messages.java b/core/src/main/java/com/friends/core/Messages.java index 5c5a2f1..9785dba 100644 --- a/core/src/main/java/com/friends/core/Messages.java +++ b/core/src/main/java/com/friends/core/Messages.java @@ -6,6 +6,7 @@ import static net.kyori.adventure.text.format.NamedTextColor.GOLD; import static net.kyori.adventure.text.format.NamedTextColor.GRAY; import static net.kyori.adventure.text.format.NamedTextColor.GREEN; +import static net.kyori.adventure.text.format.NamedTextColor.LIGHT_PURPLE; import static net.kyori.adventure.text.format.NamedTextColor.RED; import static net.kyori.adventure.text.format.NamedTextColor.YELLOW; @@ -13,8 +14,12 @@ import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; +import com.friends.api.LastSeen; +import com.friends.api.PlayerActivity; import com.friends.api.Status; import net.kyori.adventure.text.Component; @@ -36,7 +41,8 @@ private Messages() {} private static final DateTimeFormatter DATE = DateTimeFormatter.ISO_LOCAL_DATE.withZone(ZoneOffset.UTC); static final Component LINE = text("-----------------------------------------------------", NamedTextColor.BLUE, TextDecoration.STRIKETHROUGH); - record Entry(Friend friend, Status status, String server) {} // status null = offline (or appearing offline) + // status null = offline (or appearing offline); server and activity null when unknown + record Entry(Friend friend, Status status, String server, PlayerActivity activity) {} // --- building blocks --- @@ -76,21 +82,10 @@ private static Component button(String label, NamedTextColor color, String comma .hoverEvent(HoverEvent.showText(text(hover, GRAY))); } - static String ago(Instant then, Instant now) { - long s = Math.max(0, Duration.between(then, now).toSeconds()); - if (s < 60) return "just now"; - long[] sizes = {60, 60, 24, 30, 12}; - String[] units = {"minute", "hour", "day", "month", "year"}; - long n = s; - int unit = -1; - while (unit + 1 < units.length && n >= sizes[unit + 1]) n /= sizes[++unit]; - return n + " " + units[unit] + (n == 1 ? "" : "s") + " ago"; - } - // --- command replies --- // Fixed messages are built once (MiniMessage parsing costs microseconds per call). - private static final Component HELP = box( + private static final Component[] HELP_LINES = { mm("Friend Commands:"), mm("/f add \\ - Send a friend request"), mm("/f accept \\ - Accept a friend request"), @@ -102,10 +97,15 @@ static String ago(Instant then, Instant now) { mm("/f nickname \\ [nickname] - Set a nickname only you can see"), mm("/f removeall - Remove all friends except best friends"), mm("/f notifications - Toggle friend join/leave messages"), - mm("/status [online|away|busy|offline] - Set your online status")); + mm("/status [online|away|busy|offline] - Set your online status")}; + private static final Component HELP = box(HELP_LINES); + private static final Component HELP_WITH_MESSAGES = box(Stream.concat(Arrays.stream(HELP_LINES), Stream.of( + mm("/msg \\ \\ - Send a friend a private message"), + mm("/r \\ - Reply to your last private message"))).toArray(Component[]::new)); - static Component help() { - return HELP; + /** {@code privateMessages}: with {@code /msg} and {@code /r}. */ + static Component help(boolean privateMessages) { + return privateMessages ? HELP_WITH_MESSAGES : HELP; } static Component usage(String usage) { @@ -269,6 +269,40 @@ static Component statusMenu(Status current) { buttons.build()); } + // --- private messages --- + + // Hypixel's look: "From [VIP] Bob: hi" / "To [VIP] Bob: hi", the text in gray. Clicking a line starts a reply. + private static final Component FROM = text("From ", LIGHT_PURPLE); + private static final Component TO = text("To ", LIGHT_PURPLE); + private static final Component COLON = text(": ", GRAY); + + /** {@code text} is the sender's: plain text, never parsed. {@code name} is how the receiver sees them. */ + static Component messageFrom(Component name, String username, String text) { + return privateLine(FROM, name, username, text); + } + + static Component messageTo(Component name, String username, String text) { + return privateLine(TO, name, username, text); + } + + private static Component privateLine(Component direction, Component name, String username, String text) { + return text().append(direction).append(name).append(COLON).append(text(text, GRAY)) + .clickEvent(ClickEvent.suggestCommand("/msg " + username + " ")) + .hoverEvent(HoverEvent.showText(text("Click to reply", GRAY))) + .build(); + } + + static Component offline(Component friend, Instant lastSeen, Instant now) { + return box(mm(" is offline. ()", player(friend), + Placeholder.unparsed("seen", LastSeen.format(lastSeen, now)))); + } + + private static final Component NOBODY_TO_REPLY = box(mm("You have nobody to reply to! Use /msg \\ \\")); + + static Component nobodyToReply() { + return NOBODY_TO_REPLY; + } + // --- notifications --- // Sent to every online friend on each join/leave: plain builders, no parsing (same styling as @@ -318,20 +352,31 @@ static Component list(List entries, int page, int pages, boolean best, In return box(lines); } + // ponytail: "●" (U+25CF) in the status colour, not the emoji 🟢: it is in the Basic Multilingual Plane, which 1.8 + // clients' fonts cover; supplementary-plane emoji may render as boxes there. + private static final Component DOT_OFFLINE = text("● ", DARK_GRAY); + private static final Component DASH = text(" — ", DARK_GRAY); + + /** "● Bob — Playing BedWars Solo", "● Bob — Away · In lobby-1", "● Bob — Last seen 17 minutes ago". */ private static Component entry(Entry e, Instant now) { Friend f = e.friend(); Component name = friendName(f); if (f.best()) name = name.decorate(TextDecoration.BOLD); - Component state = e.status() == null ? text(" is currently offline", RED) : switch (e.status()) { - case AWAY -> text(" is away", YELLOW); - case BUSY -> text(" is busy", RED); - default -> text(e.server() == null ? " is online" : " is in " + e.server(), YELLOW); - }; var hover = text(); if (f.nickname() != null) hover.append(text("Username: ", GRAY)).append(name(f.prefix(), f.name())).append(newline()); hover.append(text("Friends since " + DATE.format(f.since()), GRAY)); - if (e.status() == null) hover.append(newline()).append(text("Last seen " + ago(f.lastSeen(), now), GRAY)); - return text().append(name).append(state).hoverEvent(HoverEvent.showText(hover.build())).build(); + if (e.status() == null) { + return text().append(DOT_OFFLINE).append(name).append(DASH).append(text(LastSeen.format(f.lastSeen(), now), GRAY)) + .hoverEvent(HoverEvent.showText(hover.build())).build(); + } + String where = e.activity() != null ? e.activity().describe() : e.server() != null ? "In " + e.server() : "Online"; + String detail = switch (e.status()) { + case AWAY, BUSY -> label(e.status()) + " · " + where; + default -> where; + }; + if (e.activity() != null && e.server() != null) hover.append(newline()).append(text("Server: " + e.server(), GRAY)); + return text().append(text("● ", color(e.status()))).append(name).append(DASH).append(text(detail, e.status() == Status.BUSY ? RED : YELLOW)) + .hoverEvent(HoverEvent.showText(hover.build())).build(); } private static final Component NO_REQUESTS = box(mm("You don't have any pending friend requests.")); diff --git a/core/src/main/java/com/friends/core/Presence.java b/core/src/main/java/com/friends/core/Presence.java index ef86df4..f95265f 100644 --- a/core/src/main/java/com/friends/core/Presence.java +++ b/core/src/main/java/com/friends/core/Presence.java @@ -1,15 +1,20 @@ package com.friends.core; +import com.friends.api.PlayerActivity; import com.friends.api.Status; -/** Where an online player is on the network. {@code server} is null until they reach a backend. */ -public record Presence(String name, String prefix, String proxy, String server, Status status) { +/** + * Where an online player is on the network. {@code server} is null until they reach a backend; {@code activity} is + * what {@code presence.rules} make of that server, or null. Shared through Redis as JSON: proxies running an older + * version ignore {@code activity}, and entries they wrote read back with a null one. + */ +public record Presence(String name, String prefix, String proxy, String server, Status status, PlayerActivity activity) { - Presence withServer(String server) { - return new Presence(name, prefix, proxy, server, status); + Presence withServer(String server, PlayerActivity activity) { + return new Presence(name, prefix, proxy, server, status, activity); } Presence withStatus(Status status) { - return new Presence(name, prefix, proxy, server, status); + return new Presence(name, prefix, proxy, server, status, activity); } } diff --git a/core/src/main/java/com/friends/core/RedisNetwork.java b/core/src/main/java/com/friends/core/RedisNetwork.java index f2ea94f..5ee6582 100644 --- a/core/src/main/java/com/friends/core/RedisNetwork.java +++ b/core/src/main/java/com/friends/core/RedisNetwork.java @@ -19,10 +19,12 @@ import org.slf4j.Logger; +import com.friends.api.PlayerActivity; import com.friends.core.Storage.PlayerRow; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import com.google.gson.JsonDeserializer; @@ -66,6 +68,20 @@ private record Envelope(String origin, String type, JsonElement event) {} static final Gson GSON = new GsonBuilder() .registerTypeAdapter(Instant.class, (JsonSerializer) (i, _, _) -> new JsonPrimitive(i.toEpochMilli())) .registerTypeAdapter(Instant.class, (JsonDeserializer) (j, _, _) -> Instant.ofEpochMilli(j.getAsLong())) + // Through the constructor: PlayerActivity is an immutable API class, not a record Gson could build. + .registerTypeAdapter(PlayerActivity.class, (JsonSerializer) (a, _, _) -> { + JsonObject o = new JsonObject(); + o.addProperty("game", a.getGame()); + o.addProperty("mode", a.getMode()); + return o; + }) + .registerTypeAdapter(PlayerActivity.class, (JsonDeserializer) (j, _, _) -> { + JsonObject o = j.getAsJsonObject(); + JsonElement game = o.get("game"); + JsonElement mode = o.get("mode"); + if (game == null || !game.isJsonPrimitive()) throw new JsonParseException("activity without a game: " + j); + return new PlayerActivity(game.getAsString(), mode == null || mode.isJsonNull() ? null : mode.getAsString()); + }) .create(); private final ExecutorService outbound = single("friends-redis-out"); // keeps our writes + publishes in order private final ExecutorService inbound = single("friends-redis-in"); // keeps resyncs + received events in order @@ -252,7 +268,7 @@ private void store(Event event) { case Event.RequestDenied(UUID from, UUID to) -> redis.del(requestKey(from, to)); case Event.Befriended(PlayerRow a, PlayerRow b, Instant _) -> redis.del(requestKey(a.id(), b.id()), requestKey(b.id(), a.id())); - case Event.Unfriended _, Event.ProxyDown _ -> {} + case Event.Unfriended _, Event.ProxyDown _, Event.PrivateMessage _ -> {} } } diff --git a/core/src/main/java/com/friends/core/Settings.java b/core/src/main/java/com/friends/core/Settings.java index 8cdd741..21ef9d2 100644 --- a/core/src/main/java/com/friends/core/Settings.java +++ b/core/src/main/java/com/friends/core/Settings.java @@ -3,10 +3,14 @@ import java.nio.file.Path; import java.sql.SQLException; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; +import java.util.Map; /** The plugin's config.yml, identical on every platform; each reads its YAML through a {@link Source}. */ -public record Settings(String storage, String sqliteFile, Mysql mysql, Redis redis, Duration requestExpiry, int maxFriends) { +public record Settings(String storage, String sqliteFile, Mysql mysql, Redis redis, Duration requestExpiry, int maxFriends, + boolean privateMessages, Activities activities, List warnings) { public record Mysql(String host, int port, String database, String user, String password, boolean ssl) {} @@ -20,6 +24,9 @@ public interface Source { int number(String path, int def); boolean flag(String path, boolean def); + + /** The raw YAML list at {@code path} (e.g. a list of maps), or empty if absent. */ + List list(String path); } public static Settings read(Source s) { @@ -27,6 +34,12 @@ public static Settings read(Source s) { if (!storage.equals("sqlite") && !storage.equals("mysql")) { throw new IllegalArgumentException("storage.type must be sqlite or mysql, not '" + storage + "'"); } + List warnings = new ArrayList<>(); + Activities activities = Activities.parse(s.list("presence.rules").stream() + .>mapMulti((rule, out) -> { + if (rule instanceof Map m) out.accept(m); + else warnings.add("presence.rules: '" + rule + "' is not a pattern/game/mode entry; skipped"); + }).toList(), warnings::add); return new Settings(storage, s.string("sqlite.file", "friends.db"), new Mysql(s.string("mysql.host", "localhost"), s.number("mysql.port", 3306), s.string("mysql.database", "friends"), s.string("mysql.user", "root"), s.string("mysql.password", ""), s.flag("mysql.use-ssl", false)), @@ -34,7 +47,8 @@ public static Settings read(Source s) { s.string("redis.password", ""), s.number("redis.database", 0), s.flag("redis.ssl", false), s.string("redis.proxy-id", ""), s.string("redis.namespace", "friends")), Duration.ofMinutes(Math.max(1, s.number("request-expiry-minutes", 5))), - Math.max(1, s.number("max-friends", 5000))); + Math.max(1, s.number("max-friends", 5000)), + s.flag("private-messages.enabled", true), activities, List.copyOf(warnings)); } public boolean usesMysql() { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 0a097fa..eb59d2c 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -1,4 +1,4 @@ -# Friends (Velocity) configuration +# Friends configuration (the same file on Velocity, BungeeCord and Paper) storage: # sqlite: a local file, fine for a single proxy. mysql: MySQL or MariaDB. @@ -34,3 +34,25 @@ redis: proxy-id: "" # Prefix for keys and the pub/sub channel, so several networks can share one Redis. namespace: friends + +# Private messages between friends: /msg (also /tell, /w, /whisper) and /r (/reply). +# false: Friends doesn't register those commands, e.g. when another plugin provides them. Plugins can also cancel +# FriendMessageEvent to deliver messages themselves. +private-messages: + enabled: true + +# What friends see a player doing, from the name of the backend server they are on (proxies only): "● Bob — Playing +# BedWars Solo" in /f list, and FriendsAPI.getActivity. Each pattern is a regular expression that must match the whole +# server name, ignoring case; the first matching rule wins; mode is optional. No match: friends see the server name. +presence: + # Example (replace the [] below with your own rules): + # rules: + # - pattern: "BW.1S.*" # BW-1S-01, bw_1s_lobby... + # game: "BedWars" + # mode: "Solo" + # - pattern: "BW.2S.*" + # game: "BedWars" + # mode: "Doubles" + # - pattern: "SW.*" + # game: "SkyWars" # no mode: "Playing SkyWars" + rules: [] diff --git a/paper/src/main/java/com/friends/paper/BukkitHooks.java b/paper/src/main/java/com/friends/paper/BukkitHooks.java index 4c8b9b6..fa988ec 100644 --- a/paper/src/main/java/com/friends/paper/BukkitHooks.java +++ b/paper/src/main/java/com/friends/paper/BukkitHooks.java @@ -9,6 +9,7 @@ import com.friends.api.Status; import com.friends.api.bukkit.FriendAddEvent; import com.friends.api.bukkit.FriendAddedEvent; +import com.friends.api.bukkit.FriendMessageEvent; import com.friends.api.bukkit.FriendRemoveEvent; import com.friends.api.bukkit.FriendRemovedEvent; import com.friends.api.bukkit.FriendRequestSendEvent; @@ -55,6 +56,12 @@ public boolean statusChanging(PlayerRow player, Status from, Status to) { return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); } + @Override + public String messaging(PlayerRow from, PlayerRow to, String message) { + FriendMessageEvent event = new FriendMessageEvent(from.id(), from.name(), to.id(), to.name(), message); + return allowed(event) ? event.getMessage() : null; + } + private boolean allowed(E event) { events.callEvent(event); // Bukkit catches (and logs) listeners' exceptions return !event.isCancelled(); diff --git a/paper/src/main/java/com/friends/paper/PaperFriends.java b/paper/src/main/java/com/friends/paper/PaperFriends.java index ae1ac04..bba6f9c 100644 --- a/paper/src/main/java/com/friends/paper/PaperFriends.java +++ b/paper/src/main/java/com/friends/paper/PaperFriends.java @@ -1,5 +1,8 @@ package com.friends.paper; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.List; import java.util.Map; @@ -9,6 +12,7 @@ import java.util.function.Function; import org.bukkit.command.Command; +import org.bukkit.command.CommandMap; import org.bukkit.command.CommandSender; import org.bukkit.command.TabExecutor; import org.bukkit.configuration.file.FileConfiguration; @@ -24,6 +28,7 @@ import com.friends.api.FriendsAPI; import com.friends.core.ChatSessions; +import com.friends.core.ConfigUpgrade; import com.friends.core.FriendCommand; import com.friends.core.FriendsRuntime; import com.friends.core.LuckPermsPrefix; @@ -53,9 +58,11 @@ public PaperFriends(JavaPlugin plugin) { public void enable() { plugin.saveDefaultConfig(); + upgradeConfig(); Storage storage = null; + Settings settings; try { - Settings settings = Settings.read(source(plugin.getConfig())); + settings = Settings.read(source(plugin.getConfig())); if (settings.redis().enabled()) log.warn("Friends: redis (multi-proxy) is for Velocity/BungeeCord; ignored on Paper"); storage = settings.openStorage(plugin.getDataFolder().toPath()); Function prefixes = plugin.getServer().getPluginManager().getPlugin("LuckPerms") != null ? LuckPermsPrefix::of : _ -> null; @@ -78,6 +85,7 @@ public void enable() { plugin.getCommand(entry.getKey()).setExecutor(command); plugin.getCommand(entry.getKey()).setTabCompleter(command); } + if (settings.privateMessages()) registerMessageCommands(); // Explicit executors: no reflective @EventHandler scanning (or generated executors) of our classes. var events = plugin.getServer().getPluginManager(); events.registerEvent(PlayerJoinEvent.class, this, EventPriority.MONITOR, @@ -96,6 +104,45 @@ public void disable() { runtime.close(); } + /** Appends the sections new versions added to the server's config.yml (their own settings and comments stay). */ + private void upgradeConfig() { + try (InputStream in = plugin.getResource("config.yml")) { + if (in == null) return; + List added = ConfigUpgrade.addMissingSections(plugin.getDataFolder().toPath().resolve("config.yml"), + new String(in.readAllBytes(), StandardCharsets.UTF_8)); + if (!added.isEmpty()) { + log.info("Friends: added the new settings {} to config.yml", added); + plugin.reloadConfig(); + } + } catch (IOException e) { + log.warn("Friends: could not add new settings to config.yml ({}); using their defaults", e.getMessage()); + } + } + + /** + * /msg and /r go into the command map at enable time, not plugin.yml: so {@code private-messages.enabled: false} + * leaves them alone entirely, and a plugin that declares them (e.g. Essentials) keeps them; ours then stay + * reachable as /friends:msg and /friends:r. They still take over vanilla's /msg, /tell and /w. + */ + private void registerMessageCommands() { + CommandMap map; + try { // public on CraftServer since 1.8 (and on Server in newer APIs), but not in the 1.8.8 API we build against + map = (CommandMap) plugin.getServer().getClass().getMethod("getCommandMap").invoke(plugin.getServer()); + } catch (ReflectiveOperationException | ClassCastException e) { + log.warn("Friends: no command map on this server ({}); /msg and /r are unavailable", e.toString()); + return; + } + register(map, new MessageCommand("msg", "msg", "Send a friend a private message", "/msg ", List.of("tell", "w", "whisper"))); + register(map, new MessageCommand("r", "reply", "Reply to your last private message", "/r ", List.of("reply"))); + } + + private void register(CommandMap map, MessageCommand command) { + if (!map.register("friends", command)) { + log.warn("Friends: another plugin owns /{}; Friends' is /friends:{} (or set private-messages.enabled: false)", + command.getName(), command.getName()); + } + } + private void join(Player player) { Online online = sessions.join(player); runtime.friends.connect(online).thenRun(() -> runtime.friends.greet(online)); @@ -111,10 +158,32 @@ private static Settings.Source source(FileConfiguration config) { @Override public String string(String path, String def) { return config.getString(path, def); } @Override public int number(String path, int def) { return config.getInt(path, def); } @Override public boolean flag(String path, boolean def) { return config.getBoolean(path, def); } + @Override public List list(String path) { List l = config.getList(path); return l == null ? List.of() : l; } }; } - /** /friend, /fl ("list") and /status ("status"). */ + /** /msg ("msg") and /r ("reply"), registered at runtime (see {@link #registerMessageCommands}). */ + private final class MessageCommand extends Command { + private final Cmd delegate; + + MessageCommand(String name, String sub, String description, String usage, List aliases) { + super(name, description, usage, aliases); + setPermission("friends.use"); + this.delegate = new Cmd(sub); + } + + @Override + public boolean execute(CommandSender sender, String label, String[] args) { + return !testPermission(sender) || delegate.onCommand(sender, this, label, args); // true: no usage line on top + } + + @Override + public List tabComplete(CommandSender sender, String alias, String[] args) { + return delegate.onTabComplete(sender, this, alias, args); + } + } + + /** /friend, /fl ("list") and /status ("status"); /msg and /r through {@link MessageCommand}. */ private final class Cmd implements TabExecutor { private final String sub; diff --git a/tools/e2e/README.md b/tools/e2e/README.md index 75990b1..4694de4 100644 --- a/tools/e2e/README.md +++ b/tools/e2e/README.md @@ -24,4 +24,7 @@ Scenarios: `paper-`, `folia-` (the Paper jar on Folia), `load- `velocity[-]` (proxy plugin, players and a Paper backend of that version, default 1.8.8), `mixed` (Alice through BungeeCord, Bob through Velocity, sharing MySQL and Redis), `api-` / `api-bungee` / `api-velocity` (another plugin, [`probe`](probe), using the API: its events, one cancelled, reads and actions from its -listeners and, on Paper, from the server thread; needs `./gradlew :probe:jar`). +listeners and, on Paper, from the server thread; needs `./gradlew :probe:jar`), `upgrade-paper` / `upgrade-velocity` +(the previous release's jar, then this build on the same database and config.yml: friendships kept, new config +sections appended, new features working; set `FRIENDS_OLD_JARS` to a folder holding the previous `friends-paper.jar` / +`friends-velocity.jar`). diff --git a/tools/e2e/run.py b/tools/e2e/run.py index 3f07df9..0bd1e2b 100644 --- a/tools/e2e/run.py +++ b/tools/e2e/run.py @@ -8,7 +8,8 @@ load- (plugin enables and answers the console), bungee[-] / velocity[-] (the proxy plugin with players and a Paper backend of that version, default 1.8.8), mixed (BungeeCord + Velocity sharing MySQL and Redis; needs FRIENDS_MYSQL=host:port/db:user:password and FRIENDS_REDIS=host:port), api- / api-bungee / api-velocity (another -plugin, tools/e2e/probe, using the API and its events). Servers run in /e2e-*. +plugin, tools/e2e/probe, using the API and its events), upgrade-paper / upgrade-velocity (the previous release's +friends-.jar from FRIENDS_OLD_JARS=

, then this build on the same data). Servers run in /e2e-*. """ import os import platform @@ -35,8 +36,12 @@ def cmd(bot, line, expect, timeout=10): return bot.wait_for(expect, mark, timeout) +# On the proxies, the backend "lobby" counts as a BedWars Solo server: /f list shows the activity, synced across proxies. +PRESENCE = {"presence.rules": [{"pattern": "LOB.*", "game": "BedWars", "mode": "Solo"}]} + + def friend_flow(alice, bob, where): - """The same player story on every platform. `where`: how Alice sees Bob online ("is online" / "is in lobby").""" + """The same player story on every platform. `where`: how Alice sees Bob online ("— Online" / "— Playing ...").""" cmd(alice, "/f", "Friend Commands:") cmd(alice, "/f add Bob", "You sent a friend request to Bob! They have 5 minutes to accept it!") _, raw = bob.wait_for("Friend request from Alice") @@ -44,14 +49,23 @@ def friend_flow(alice, bob, where): mark = alice.mark() cmd(bob, "/f accept Alice", "You are now friends with Alice") alice.wait_for("You are now friends with Bob", mark) - cmd(alice, "/fl", f"Bob {where}") + cmd(alice, "/fl", f"● Bob {where}") cmd(bob, "/status busy", "Your status is now Busy.") - cmd(alice, "/f list", "Bob is busy") + cmd(alice, "/f list", "● Bob — Busy · ") cmd(bob, "/status online", "Your status is now Online.") + mark = bob.mark() + cmd(alice, "/msg Bob hi there &c", "To Bob: hi there &c") + _, raw = bob.wait_for("From Alice: hi there &c", mark) + assert "/msg Alice " in raw, f"no click-to-reply: {raw}" + mark = alice.mark() + cmd(bob, "/r hello back", "To Alice: hello back") + alice.wait_for("From Bob: hello back", mark) cmd(alice, "/f nickname Bob Bobby", "You'll now see Bob as Bobby.") mark = alice.mark() bob.close() alice.wait_for("Friend > Bobby left.", mark) + cmd(alice, "/tell Bob still there?", "Bobby is offline. (Last seen just now)") # /tell: ours, not vanilla's + cmd(alice, "/fl", "● Bobby — Last seen just now") mark = alice.mark() bob.connect() alice.wait_for("Friend > Bobby joined.", mark) @@ -93,7 +107,7 @@ def scenario_paper(jars, version, kind="paper"): s.start() s.wait_log(r"Enabling Friends") alice, bob = players(port, PROTOCOL[version]) - friend_flow(alice, bob, "is online") + friend_flow(alice, bob, "— Online") for b in (alice, bob): b.close() check_clean(s) @@ -130,11 +144,12 @@ def scenario_proxy(jars, kind, version="1.8.8"): make = servers.bungee if kind == "bungee" else servers.velocity jar = os.path.join(jars, "BungeeCord.jar" if kind == "bungee" else "velocity.jar") proxy = make(os.path.join(jars, f"e2e-{kind}-{version}"), jar, proxy_port, back_port, PLUGIN[kind]) + servers.plugin_config(proxy.folder, **PRESENCE) try: back.start() proxy.start() alice, bob = players(proxy_port, PROTOCOL[version]) - friend_flow(alice, bob, "is in lobby") + friend_flow(alice, bob, "— Playing BedWars Solo") for b in (alice, bob): b.close() check_clean(proxy) @@ -154,11 +169,11 @@ def api_flow(srv, alice, bob): r"probe: areFriends=true friends of Bob=\[Alice\]", r"probe: FriendStatusChangeEvent Bob ONLINE->BUSY", r"probe: setStatus Bob BUSY -> SUCCESS"): srv.wait_log(line, mark) - cmd(alice, "/fl", "Bob is busy") # the probe's API call, as players see it + cmd(alice, "/fl", "Bob — Busy") # the probe's API call, as players see it bob.say("/status away") # the probe cancels this one: no reply, nothing changes srv.wait_log(r"probe: FriendStatusChangeEvent Bob BUSY->AWAY cancelled", mark) time.sleep(PAUSE) - cmd(alice, "/fl", "Bob is busy") + cmd(alice, "/fl", "Bob — Busy") cmd(alice, "/f remove Bob", "You removed Bob from your friends list!") srv.wait_log(r"probe: FriendRemoveEvent Alice->Bob", mark) srv.wait_log(r"probe: FriendRemovedEvent Alice->Bob", mark) @@ -197,7 +212,7 @@ def scenario_api(jars, where): def scenario_mixed(jars): rhost, rport = os.environ["FRIENDS_REDIS"].split(":") - shared = {**mysql_settings(), "redis.enabled": True, "redis.host": rhost, "redis.port": int(rport), + shared = {**mysql_settings(), **PRESENCE, "redis.enabled": True, "redis.host": rhost, "redis.port": int(rport), "redis.namespace": f"friends-e2e-{int(time.time())}"} back = backend(jars, 25790) bungee = servers.bungee(os.path.join(jars, "e2e-mixed-bungee"), os.path.join(jars, "BungeeCord.jar"), 25791, 25790, PLUGIN["bungee"]) @@ -212,7 +227,7 @@ def scenario_mixed(jars): time.sleep(0.5) bob = Bot("Bob", 25792).connect() # through Velocity time.sleep(2) - friend_flow(alice, bob, "is in lobby") + friend_flow(alice, bob, "— Playing BedWars Solo") # Bob's activity (Velocity) seen by Alice (BungeeCord) for b in (alice, bob): b.close() check_clean(bungee, velocity) @@ -222,6 +237,53 @@ def scenario_mixed(jars): back.stop() +def scenario_upgrade(jars, kind): + """The previous release (FRIENDS_OLD_JARS/friends-.jar), then this build on the same database and config.""" + old = os.path.join(os.environ["FRIENDS_OLD_JARS"], f"friends-{kind}.jar") + if kind == "paper": + back, port, protocol, stop, data = None, 25820, PROTOCOL["26.3"], "stop", "Friends" + srv = servers.paper(os.path.join(jars, "e2e-upgrade-paper"), os.path.join(jars, "paper-26.3.jar"), port, old) + else: + back, port, protocol, stop, data = backend(jars, 25822), 25823, 47, "end", "friends" + srv = servers.velocity(os.path.join(jars, "e2e-upgrade-velocity"), os.path.join(jars, "velocity.jar"), port, 25822, old) + config = os.path.join(srv.folder, "plugins", data, "config.yml") + try: + if back: + back.start() + srv.start() + alice, bob = players(port, protocol) + cmd(alice, "/f add Bob", "You sent a friend request to Bob!") + cmd(bob, "/f accept Alice", "You are now friends with Alice") + cmd(alice, "/f nickname Bob Bobby", "You'll now see Bob as Bobby.") + for b in (alice, bob): + b.close() + srv.stop(stop) + with open(config) as f: + before = f.read() + assert "presence:" not in before, "not an older config" + + os.remove(os.path.join(srv.folder, "plugins", os.path.basename(old))) + shutil.copy(PLUGIN[kind], os.path.join(srv.folder, "plugins")) + srv.lines = [] + srv.start() + srv.wait_log(r"added the new settings \[private-messages, presence\] to config.yml") + with open(config) as f: + after = f.read() + assert after.startswith(before) and "\npresence:\n" in after, "the old config.yml is kept, new sections appended" + alice, bob = players(port, protocol) + cmd(alice, "/fl", "● Bobby — ") # the friendship and nickname survived, shown the new way + mark = bob.mark() + cmd(alice, "/w Bob upgraded", "To Bobby: upgraded") # /w: ours, not vanilla's + bob.wait_for("From Alice: upgraded", mark) + for b in (alice, bob): + b.close() + check_clean(srv) + finally: + srv.stop(stop) + if back: + back.stop() + + def main(): jars = os.path.abspath(sys.argv[1]) wanted = sys.argv[2:] or [f"paper-{v}" for v in PROTOCOL] + ["bungee", "velocity", "bungee-26.3", "velocity-26.3", @@ -242,6 +304,8 @@ def main(): scenario_proxy(jars, kind, version or "1.8.8") elif name == "mixed": scenario_mixed(jars) + elif name.startswith("upgrade-"): + scenario_upgrade(jars, name[8:]) else: raise ValueError(f"unknown scenario {name}") print(f"PASS {name} ({time.time() - start:.0f}s)", flush=True) diff --git a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java index cb13078..2f12fd1 100644 --- a/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java +++ b/velocity/src/main/java/com/friends/velocity/FriendsVelocity.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.SQLException; @@ -18,6 +19,7 @@ import org.spongepowered.configurate.ConfigurationNode; import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import com.friends.core.ConfigUpgrade; import com.friends.core.FriendCommand; import com.friends.core.FriendsRuntime; import com.friends.core.LuckPermsPrefix; @@ -68,8 +70,9 @@ public FriendsVelocity(ProxyServer proxy, Logger log, @DataDirectory Path dataDi @Subscribe public void onInit(ProxyInitializeEvent event) { Storage storage = null; + Settings settings; try { - Settings settings = Settings.read(source(loadConfig())); + settings = Settings.read(source(loadConfig())); storage = settings.openStorage(dataDir); Network network = RedisNetwork.open(settings, log); Function prefixes = proxy.getPluginManager().isLoaded("luckperms") ? LuckPermsPrefix::of : _ -> null; @@ -85,6 +88,13 @@ public void onInit(ProxyInitializeEvent event) { commands.register(commands.metaBuilder("friend").aliases("f", "friends").plugin(this).build(), new Cmd(runtime.command, platform, null, commandThreads)); commands.register(commands.metaBuilder("fl").plugin(this).build(), new Cmd(runtime.command, platform, "list", commandThreads)); commands.register(commands.metaBuilder("status").plugin(this).build(), new Cmd(runtime.command, platform, "status", commandThreads)); + if (settings.privateMessages()) { + for (String alias : List.of("msg", "tell", "w", "whisper", "r", "reply")) { + if (commands.hasCommand(alias)) log.warn("Friends: /{} is already registered by another plugin; Friends replaces it (private-messages.enabled: false keeps theirs)", alias); + } + commands.register(commands.metaBuilder("msg").aliases("tell", "w", "whisper").plugin(this).build(), new Cmd(runtime.command, platform, "msg", commandThreads)); + commands.register(commands.metaBuilder("r").aliases("reply").plugin(this).build(), new Cmd(runtime.command, platform, "reply", commandThreads)); + } } @Subscribe @@ -114,16 +124,21 @@ public void onShutdown(ProxyShutdownEvent event) { private ConfigurationNode loadConfig() throws IOException { Path file = dataDir.resolve("config.yml"); + String bundled; + try (InputStream in = FriendsVelocity.class.getResourceAsStream("/config.yml")) { + bundled = new String(Objects.requireNonNull(in, "config.yml missing from the plugin jar").readAllBytes(), StandardCharsets.UTF_8); + } if (Files.notExists(file)) { Files.createDirectories(dataDir); - try (InputStream in = FriendsVelocity.class.getResourceAsStream("/config.yml")) { - Files.copy(Objects.requireNonNull(in, "config.yml missing from the plugin jar"), file); - } + Files.writeString(file, bundled); + } else { + List added = ConfigUpgrade.addMissingSections(file, bundled); + if (!added.isEmpty()) log.info("Friends: added the new settings {} to config.yml", added); } return YamlConfigurationLoader.builder().path(file).build().load(); } - private static Settings.Source source(ConfigurationNode root) { + static Settings.Source source(ConfigurationNode root) { return new Settings.Source() { private ConfigurationNode node(String path) { return root.node((Object[]) path.split("\\.")); @@ -132,6 +147,7 @@ private ConfigurationNode node(String path) { @Override public String string(String path, String def) { return node(path).getString(def); } @Override public int number(String path, int def) { return node(path).getInt(def); } @Override public boolean flag(String path, boolean def) { return node(path).getBoolean(def); } + @Override public List list(String path) { return node(path).raw() instanceof List l ? l : List.of(); } }; } @@ -147,7 +163,7 @@ public Optional player(UUID id) { } } - /** Routes /friend, /fl ("list") and /status ("status") into the shared command parser. */ + /** Routes /friend, /fl ("list"), /status ("status"), /msg ("msg") and /r ("reply") into the shared command parser. */ record Cmd(FriendCommand command, VelocityPlatform platform, String sub, Executor commands) implements SimpleCommand { @Override public void execute(Invocation invocation) { diff --git a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java index 2b5b6f8..298e840 100644 --- a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java +++ b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java @@ -7,6 +7,7 @@ import com.friends.api.Status; import com.friends.api.velocity.FriendAddEvent; import com.friends.api.velocity.FriendAddedEvent; +import com.friends.api.velocity.FriendMessageEvent; import com.friends.api.velocity.FriendRemoveEvent; import com.friends.api.velocity.FriendRemovedEvent; import com.friends.api.velocity.FriendRequestSendEvent; @@ -54,6 +55,12 @@ public boolean statusChanging(PlayerRow player, Status from, Status to) { return allowed(new FriendStatusChangeEvent(player.id(), player.name(), from, to)); } + @Override + public String messaging(PlayerRow from, PlayerRow to, String message) { + FriendMessageEvent event = new FriendMessageEvent(from.id(), from.name(), to.id(), to.name(), message); + return allowed(event) ? event.getMessage() : null; + } + /** Waits for every listener, then reads their verdict. */ private boolean allowed(ResultedEvent event) { fire.apply(event).join();