diff --git a/README.md b/README.md index 05177c5..1f4bb71 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ SQLite driver: Paper 1.12.2's has no Apple Silicon build (Linux x86_64/ARM are f | `/f nickname [nickname]` | Nickname only you can see (in your list and notifications). No nickname = clear. | | `/f removeall [confirm]` | Remove every friend except best friends, after a clickable confirmation. | | `/f notifications` | Toggle "Friend > X joined./left." messages. | +| `/f ignore ` / `/f unignore ` | Block a player (anyone who joined before): no private messages or friend requests from them, either way. Silently ends a friendship with them (nobody is told) and drops pending requests. `/f ignore` alone lists who you ignore. | | `/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`): @@ -59,6 +60,12 @@ Private messages (friends only, network-wide; turn off with `private-messages.en | `/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. | +- **Anti-spam:** at most 3 messages per 3 seconds per player (`private-messages.rate-limit`); over that, the message + is blocked and the sender told how long to wait. +- **AFK auto-reply:** a friend whose status is away (`/status away`) still gets the message, and the sender sees + "From Bob: I'm currently AFK, and will answer when I am available. (auto-reply)", at most once every 5 minutes per + friend. + `/f list` shows each friend's presence: ``` @@ -74,7 +81,8 @@ permission check (no permission defaults there). The same file on every platform: `storage.type` `sqlite` or `mysql` (MySQL/MariaDB), `request-expiry-minutes` (default 5), `max-friends` (default 5000), the `redis` section (proxies only; ignored on Paper), -`private-messages.enabled` (default true) and `presence.rules` (proxies only): +`private-messages.enabled` (default true), `private-messages.rate-limit` (`messages: 3` per `seconds: 3`; 0 = no +limit) and `presence.rules` (proxies only): ```yaml presence: @@ -93,9 +101,14 @@ A rule with an invalid regex or without a game is skipped with a warning at star ## 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. +Replace the jar and restart: no database migration (new tables are created at startup). What a new version adds to +the config is added to your `config.yml` with its comments at startup: new sections at the end (0.6.0: +`private-messages` and `presence`), new keys at the end of their section, indented like yours (0.7.0: +`private-messages.rate-limit`). Nothing you wrote is changed; the startup log lists what was added. +- **0.7.0 adds `/f ignore`, `/f unignore`, the message rate limit and AFK auto-replies.** Ignore lists live in a new + `friends_ignores` table. On a multi-proxy network, upgrade every proxy: a proxy still on 0.6.0 doesn't check ignore + lists, so players on it can still send friend requests to someone who ignores them (who can't accept them). - **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). @@ -119,8 +132,10 @@ 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, 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 +requests, activity, actions (request, accept, deny, remove, best friend, nickname, status, private message) and +events on every platform (including a cancellable `FriendMessageEvent` to plug in your own chat system, and +`PlayerActivityChangeEvent` on the proxies). `LastSeen` formats presence for other UIs ("🟢 Online", +"⚫ Last seen 3 days ago", "🟢 Bob — Playing BedWars Solo"). See [`api/README.md`](api/README.md) for setup, examples and the threading rules. ## How it works @@ -141,7 +156,8 @@ platform (including a cancellable `FriendMessageEvent` to plug in your own chat - 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 +- Rate-limit and AFK-reply bookkeeping lives with each online player's cached data and goes away when they leave. +- Tables are `friends_players`, `friends_friendships` and `friends_ignores`. Data from the old Bungee/Paper tables (`players`, `friends`, `requests`, `settings`) is not migrated. ## Tests diff --git a/api/README.md b/api/README.md index 37baca5..c588d65 100644 --- a/api/README.md +++ b/api/README.md @@ -1,8 +1,8 @@ # 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 seven events -(`com.friends.api.bukkit`, `.velocity`, `.bungee`). The API is Java 8 bytecode with no dependencies of its own, so +The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has its events +(`com.friends.api.bukkit`, `.velocity`, `.bungee`: seven, plus `PlayerActivityChangeEvent` on the proxies). 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`). ## 1. Add the dependency @@ -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.6.0") } +dependencies { compileOnly("com.friends:friends-api:0.7.0") } ``` Then make your plugin load after Friends: @@ -67,7 +67,15 @@ For menus, scoreboards and other UIs, `LastSeen` words times like Friends' chat 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" + +// Outside the game (websites, Discord, menus with emoji fonts): one string with an emoji. +boolean visible = friends.getStatus(id).filter(s -> s != Status.OFFLINE).isPresent(); +LastSeen.presence(visible, friend.getLastSeen()); // "🟢 Online" or "⚫ Last seen 17 minutes ago" +LastSeen.line(friend.getName(), visible, friends.getActivity(id).orElse(null), friend.getLastSeen()); + // "🟢 Bob — Playing BedWars Solo", "⚫ Bob — Last seen 3 days ago" ``` + +In game, Friends' own chat uses a coloured `●` instead: 1.8 clients' fonts have no `🟢`. `FriendRequest` gives `getSender()`, `getSenderName()`, `getTarget()`, `getTargetName()` and `getExpiresAt()`. ## 4. Load and act (asynchronous) @@ -85,9 +93,21 @@ friends.sendRequest(alice, bob).thenAccept(result -> { }); ``` -The actions are `sendRequest`, `acceptRequest`, `denyRequest`, `removeFriend`, `setBestFriend`, `setNickname` and -`setStatus`. Each one behaves like its command, events and limits included. The acting player must be loaded, and -gets no chat reply: the `Result` tells you what happened. The other player is still notified. +The actions are `sendRequest`, `acceptRequest`, `denyRequest`, `removeFriend`, `setBestFriend`, `setNickname`, +`setStatus` and `sendMessage`. Each one behaves like its command, events and limits included. The acting player must +be loaded, and gets no chat reply: the `Result` tells you what happened. The other player is still notified. + +```java +friends.sendMessage(alice, bob, "GG!").thenAccept(result -> { + // SUCCESS (delivered), NOT_FRIENDS, NOT_ONLINE, RATE_LIMITED (private-messages.rate-limit), CANCELLED (an event + // listener took over), NOT_LOADED, INVALID_ARGUMENT +}); +``` + +`sendMessage` is `/msg`: friends only, rate-limited, and it fires `FriendMessageEvent`. If Bob's status is away, Alice +still gets his AFK auto-reply in her chat. Someone who ignores Alice has also removed her as a friend, so she gets +`NOT_FRIENDS`: an ignore is never revealed. `sendRequest` between two players where one ignores the other gives +`IGNORED`. ## 5. Listen to events @@ -99,7 +119,13 @@ 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 | +| `FriendMessageEvent` | yes | before a private message (`/msg`, `/r`, `sendMessage`) is delivered; `setMessage` rewrites it | +| `PlayerActivityChangeEvent` | no | Velocity and BungeeCord: after a player's activity changes (join, server switch, leave) | + +`/f ignore` ends a friendship without `FriendRemoveEvent` (it can't be vetoed); `FriendRemovedEvent` still fires. +`PlayerActivityChangeEvent` has `getPlayerId()`, `getPlayerName()`, `getOldActivity()` and `getNewActivity()` +(`Optional`, empty when no `presence.rules` pattern matches, or on join/leave). It reports players who +appear offline too: check `getStatus` before showing it to anyone. Pair events have `getPlayerId()` / `getPlayerName()` (the acting player) and `getTargetId()` / `getTargetName()`. The status event has `getOldStatus()` / `getNewStatus()`. `FriendAddedEvent` also has `getSince()`. diff --git a/api/src/main/java/com/friends/api/FriendsAPI.java b/api/src/main/java/com/friends/api/FriendsAPI.java index d86f32e..b5d5893 100644 --- a/api/src/main/java/com/friends/api/FriendsAPI.java +++ b/api/src/main/java/com/friends/api/FriendsAPI.java @@ -114,6 +114,7 @@ static Optional find() { * @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 + * @see LastSeen#line */ Optional getActivity(UUID player); @@ -155,7 +156,8 @@ static Optional find() { * @return {@link Result#SUCCESS} (sent), {@link Result#BECAME_FRIENDS}, or why not: {@link Result#NOT_LOADED}, * {@link Result#SELF}, {@link Result#PLAYER_NOT_FOUND}, {@link Result#ALREADY_FRIENDS}, * {@link Result#ALREADY_REQUESTED}, {@link Result#LIMIT_REACHED}, {@link Result#TARGET_LIMIT_REACHED}, - * {@link Result#CANCELLED}, {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} + * {@link Result#IGNORED} (either one ignores the other), {@link Result#CANCELLED}, + * {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} */ CompletableFuture sendRequest(UUID sender, UUID target); @@ -166,8 +168,8 @@ static Optional find() { * @param player a loaded player who received a request * @param sender who sent it * @return {@link Result#SUCCESS}, or {@link Result#NOT_LOADED}, {@link Result#NO_REQUEST}, - * {@link Result#LIMIT_REACHED}, {@link Result#TARGET_LIMIT_REACHED}, {@link Result#CANCELLED}, - * {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} + * {@link Result#LIMIT_REACHED}, {@link Result#TARGET_LIMIT_REACHED}, {@link Result#IGNORED} ({@code player} + * ignores {@code sender}), {@link Result#CANCELLED}, {@link Result#INVALID_ARGUMENT}, {@link Result#ERROR} */ CompletableFuture acceptRequest(UUID player, UUID sender); @@ -222,4 +224,23 @@ static Optional find() { * {@link Result#INVALID_ARGUMENT} */ CompletableFuture setStatus(UUID player, Status status); + + /** + * {@code /msg}: sends a friend a private message, with every rule of the command: the receiver must be a friend + * who is visibly online anywhere on the network, the sender's {@code private-messages.rate-limit} applies, and + * the message event fires first (a plugin may cancel or rewrite it). Works with {@code private-messages.enabled: + * false} too, which only leaves the commands to another plugin. + * + *

The sender sees no "To ..." echo (they get no chat reply, like every action), but a friend whose status is + * {@link Status#AWAY} still answers with the AFK auto-reply in their chat. Ignoring a player removes them as a + * friend, so messaging someone who ignores you gives {@link Result#NOT_FRIENDS}: an ignore is never revealed. + * + * @param sender a loaded player + * @param receiver one of their friends + * @param message plain text, never parsed (colour codes and control characters are removed) + * @return {@link Result#SUCCESS} (delivered), or {@link Result#NOT_LOADED}, {@link Result#NOT_FRIENDS}, + * {@link Result#NOT_ONLINE}, {@link Result#RATE_LIMITED}, {@link Result#CANCELLED}, + * {@link Result#INVALID_ARGUMENT} (null, or nothing printable), {@link Result#ERROR} + */ + CompletableFuture sendMessage(UUID sender, UUID receiver, String message); } diff --git a/api/src/main/java/com/friends/api/LastSeen.java b/api/src/main/java/com/friends/api/LastSeen.java index af59fdf..db7d884 100644 --- a/api/src/main/java/com/friends/api/LastSeen.java +++ b/api/src/main/java/com/friends/api/LastSeen.java @@ -4,12 +4,18 @@ 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"}. + * Human-readable "last seen" times and presence lines, as Friends shows them in chat; for menus, scoreboards, websites + * and other plugins' UIs. Example: {@code LastSeen.format(friend.getLastSeen())} gives + * {@code "Last seen 17 minutes ago"}, and {@link #line} gives {@code "🟢 Bob — Playing BedWars Solo"}. */ public final class LastSeen { private static final long[] SIZES = {60, 60, 24, 30, 12}; private static final String[] UNITS = {"minute", "hour", "day", "month", "year"}; + // ponytail: emoji for outside the game only. Chat in game uses a coloured "●" (U+25CF): 1.8 clients' fonts have + // no supplementary-plane emoji like 🟢 (U+1F7E2) and may draw boxes. + private static final String ONLINE = "\uD83D\uDFE2 "; + private static final String OFFLINE = "\u26AB "; + private static final String DASH = " \u2014 "; private LastSeen() {} @@ -49,4 +55,59 @@ public static String format(Instant then, Instant now) { public static String format(Instant then) { return format(then, Instant.now()); } + + /** + * A player's presence in a few words, with an emoji. + * + * @param online whether they are visibly online ({@code FriendsAPI.getStatus} is present and not + * {@link Status#OFFLINE}) + * @param lastSeen when they were last online, e.g. {@link Friend#getLastSeen()}; unused if {@code online} + * @param now the current instant + * @return {@code "🟢 Online"}, or e.g. {@code "⚫ Last seen 17 minutes ago"} ({@code "⚫ Offline"} if + * {@code lastSeen} is null) + */ + public static String presence(boolean online, Instant lastSeen, Instant now) { + if (online) return ONLINE + "Online"; + return OFFLINE + (lastSeen == null ? "Offline" : format(lastSeen, now)); + } + + /** + * {@link #presence(boolean, Instant, Instant)} as of now. + * + * @param online whether they are visibly online + * @param lastSeen when they were last online; unused if {@code online} + * @return e.g. {@code "🟢 Online"} or {@code "⚫ Last seen 3 days ago"} + */ + public static String presence(boolean online, Instant lastSeen) { + return presence(online, lastSeen, Instant.now()); + } + + /** + * Rich presence, like a line of {@code /f list}: what an online player is doing, else when they were last seen. + * + * @param name the name to show (e.g. {@link Friend#getName()} or the nickname) + * @param online whether they are visibly online + * @param activity what they are doing ({@code FriendsAPI.getActivity}), or null; unused if offline + * @param lastSeen when they were last online; unused if {@code online} + * @param now the current instant + * @return e.g. {@code "🟢 Bob — Playing BedWars Solo"}, {@code "🟢 Bob — Online"} (no activity) or + * {@code "⚫ Bob — Last seen 3 days ago"} + */ + public static String line(String name, boolean online, PlayerActivity activity, Instant lastSeen, Instant now) { + if (online) return ONLINE + name + DASH + (activity != null ? activity.describe() : "Online"); + return OFFLINE + name + DASH + (lastSeen == null ? "Offline" : format(lastSeen, now)); + } + + /** + * {@link #line(String, boolean, PlayerActivity, Instant, Instant)} as of now. + * + * @param name the name to show + * @param online whether they are visibly online + * @param activity what they are doing, or null; unused if offline + * @param lastSeen when they were last online; unused if {@code online} + * @return e.g. {@code "🟢 Bob — Playing BedWars Solo"} or {@code "⚫ Bob — Last seen 17 minutes ago"} + */ + public static String line(String name, boolean online, PlayerActivity activity, Instant lastSeen) { + return line(name, online, activity, lastSeen, Instant.now()); + } } diff --git a/api/src/main/java/com/friends/api/Result.java b/api/src/main/java/com/friends/api/Result.java index 1511eaa..8d61564 100644 --- a/api/src/main/java/com/friends/api/Result.java +++ b/api/src/main/java/com/friends/api/Result.java @@ -1,6 +1,6 @@ package com.friends.api; -/** Outcome of a {@link FriendsAPI} action. */ +/** Outcome of a {@link FriendsAPI} action. New constants are only ever added last, so ordinals never move. */ public enum Result { /** Done: the request was sent, accepted or denied, the friend removed, or the setting changed. */ SUCCESS, @@ -30,8 +30,12 @@ public enum Result { CANCELLED, /** A database error (Friends logs it). */ ERROR, - /** The target is offline, or appearing offline (private messages). Last, so earlier ordinals never move. */ - NOT_ONLINE; + /** The target is offline, or appearing offline (private messages). */ + NOT_ONLINE, + /** The sender is sending private messages too fast ({@code private-messages.rate-limit}). */ + RATE_LIMITED, + /** One of the two players ignores the other ({@code /f ignore}): no friend requests between them. */ + IGNORED; /** * Whether the action happened. diff --git a/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java index 5a8c9c0..c9f7570 100644 --- a/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java +++ b/api/src/main/java/com/friends/api/bukkit/FriendRemoveEvent.java @@ -7,7 +7,8 @@ /** * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: - * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * cancelling one keeps that friendship, the others in a {@code removeall} still go. Not fired for {@code /f ignore}, + * which always ends the friendship ({@link FriendRemovedEvent} still follows). * *

To react to the removal itself, listen to {@link FriendRemovedEvent}. */ diff --git a/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java index edec53f..519c584 100644 --- a/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java +++ b/api/src/main/java/com/friends/api/bungee/FriendRemoveEvent.java @@ -6,7 +6,8 @@ /** * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: - * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * cancelling one keeps that friendship, the others in a {@code removeall} still go. Not fired for {@code /f ignore}, + * which always ends the friendship ({@link FriendRemovedEvent} still follows). * *

To react to the removal itself, listen to {@link FriendRemovedEvent}. */ diff --git a/api/src/main/java/com/friends/api/bungee/PlayerActivityChangeEvent.java b/api/src/main/java/com/friends/api/bungee/PlayerActivityChangeEvent.java new file mode 100644 index 0000000..2b546d5 --- /dev/null +++ b/api/src/main/java/com/friends/api/bungee/PlayerActivityChangeEvent.java @@ -0,0 +1,81 @@ +package com.friends.api.bungee; + +import java.util.Optional; +import java.util.UUID; + +import com.friends.api.FriendsAPI; +import com.friends.api.PlayerActivity; + +import net.md_5.bungee.api.plugin.Event; + +/** + * What a player is doing changed: they joined, switched backend server or left, and the {@code presence.rules} of + * Friends' config give them another {@link PlayerActivity} (or none) than before. Switching between two servers of + * the same game and mode fires nothing. Not cancellable: fired after the change, on Friends' threads, on the proxy + * the player is on only (so once network-wide; players of a proxy that stops responding are forgotten without it). + * + *

This reports the activity as is, also for players appearing offline, whose activity + * {@link FriendsAPI#getActivity} hides from their friends: check {@link FriendsAPI#getStatus} before showing it. + */ +public final class PlayerActivityChangeEvent extends Event { + private final UUID playerId; + private final String playerName; + private final PlayerActivity oldActivity; + private final PlayerActivity newActivity; + + /** + * Created by Friends. + * + * @param playerId the player + * @param playerName their name + * @param oldActivity what they were doing, or null for nothing (just joined, or no rule matched) + * @param newActivity what they are doing now, or null for nothing (left, or no rule matches) + */ + public PlayerActivityChangeEvent(UUID playerId, String playerName, PlayerActivity oldActivity, PlayerActivity newActivity) { + this.playerId = playerId; + this.playerName = playerName; + this.oldActivity = oldActivity; + this.newActivity = newActivity; + } + + /** + * The player. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The activity before the change. + * + * @return e.g. BedWars / Solo, or empty if they were doing nothing a rule knows (or had just joined) + */ + public Optional getOldActivity() { + return Optional.ofNullable(oldActivity); + } + + /** + * The activity after the change. + * + * @return e.g. SkyWars, or empty if they are doing nothing a rule knows (or left) + */ + public Optional getNewActivity() { + return Optional.ofNullable(newActivity); + } + + @Override + public String toString() { + return "PlayerActivityChangeEvent{player=" + playerName + ", old=" + oldActivity + ", new=" + newActivity + "}"; + } +} diff --git a/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java b/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java index 047078a..76565c7 100644 --- a/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java +++ b/api/src/main/java/com/friends/api/velocity/FriendRemoveEvent.java @@ -7,7 +7,8 @@ /** * A player is about to remove a friend ({@code /f remove}, {@code /f removeall} or the API). Fired once per friend: - * cancelling one keeps that friendship, the others in a {@code removeall} still go. + * cancelling one keeps that friendship, the others in a {@code removeall} still go. Not fired for {@code /f ignore}, + * which always ends the friendship ({@link FriendRemovedEvent} still follows). * *

To react to the removal itself, listen to {@link FriendRemovedEvent}. */ diff --git a/api/src/main/java/com/friends/api/velocity/PlayerActivityChangeEvent.java b/api/src/main/java/com/friends/api/velocity/PlayerActivityChangeEvent.java new file mode 100644 index 0000000..4472cfc --- /dev/null +++ b/api/src/main/java/com/friends/api/velocity/PlayerActivityChangeEvent.java @@ -0,0 +1,79 @@ +package com.friends.api.velocity; + +import java.util.Optional; +import java.util.UUID; + +import com.friends.api.FriendsAPI; +import com.friends.api.PlayerActivity; + +/** + * What a player is doing changed: they joined, switched backend server or left, and the {@code presence.rules} of + * Friends' config give them another {@link PlayerActivity} (or none) than before. Switching between two servers of + * the same game and mode fires nothing. Not cancellable: fired after the change, on Friends' threads, on the proxy + * the player is on only (so once network-wide; players of a proxy that stops responding are forgotten without it). + * + *

This reports the activity as is, also for players appearing offline, whose activity + * {@link FriendsAPI#getActivity} hides from their friends: check {@link FriendsAPI#getStatus} before showing it. + */ +public final class PlayerActivityChangeEvent { + private final UUID playerId; + private final String playerName; + private final PlayerActivity oldActivity; + private final PlayerActivity newActivity; + + /** + * Created by Friends. + * + * @param playerId the player + * @param playerName their name + * @param oldActivity what they were doing, or null for nothing (just joined, or no rule matched) + * @param newActivity what they are doing now, or null for nothing (left, or no rule matches) + */ + public PlayerActivityChangeEvent(UUID playerId, String playerName, PlayerActivity oldActivity, PlayerActivity newActivity) { + this.playerId = playerId; + this.playerName = playerName; + this.oldActivity = oldActivity; + this.newActivity = newActivity; + } + + /** + * The player. + * + * @return their UUID, never null + */ + public UUID getPlayerId() { + return playerId; + } + + /** + * The player's name. + * + * @return never null + */ + public String getPlayerName() { + return playerName; + } + + /** + * The activity before the change. + * + * @return e.g. BedWars / Solo, or empty if they were doing nothing a rule knows (or had just joined) + */ + public Optional getOldActivity() { + return Optional.ofNullable(oldActivity); + } + + /** + * The activity after the change. + * + * @return e.g. SkyWars, or empty if they are doing nothing a rule knows (or left) + */ + public Optional getNewActivity() { + return Optional.ofNullable(newActivity); + } + + @Override + public String toString() { + return "PlayerActivityChangeEvent{player=" + playerName + ", old=" + oldActivity + ", new=" + newActivity + "}"; + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e7b86d2..13bd2b2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { allprojects { group = "com.friends" - version = "0.6.0" + version = "0.7.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 64b2169..4155949 100644 --- a/bungee/src/main/java/com/friends/bungee/BungeeHooks.java +++ b/bungee/src/main/java/com/friends/bungee/BungeeHooks.java @@ -3,6 +3,7 @@ import java.time.Instant; import java.util.function.Consumer; +import com.friends.api.PlayerActivity; import com.friends.api.Status; import com.friends.api.bungee.FriendAddEvent; import com.friends.api.bungee.FriendAddedEvent; @@ -11,6 +12,7 @@ import com.friends.api.bungee.FriendRemovedEvent; import com.friends.api.bungee.FriendRequestSendEvent; import com.friends.api.bungee.FriendStatusChangeEvent; +import com.friends.api.bungee.PlayerActivityChangeEvent; import com.friends.core.Friend; import com.friends.core.Hooks; import com.friends.core.Storage.PlayerRow; @@ -62,6 +64,11 @@ public String messaging(PlayerRow from, PlayerRow to, String message) { return allowed(event) ? event.getMessage() : null; } + @Override + public void activityChanged(PlayerRow player, PlayerActivity from, PlayerActivity to) { + call.accept(new PlayerActivityChangeEvent(player.id(), player.name(), from, to)); + } + private boolean allowed(E event) { call.accept(event); // BungeeCord's event bus catches (and logs) listeners' exceptions return !event.isCancelled(); diff --git a/core/build.gradle.kts b/core/build.gradle.kts index d361ef5..6ef7bea 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -52,6 +52,7 @@ for (version in listOf("3.7.2", "3.21.0.1")) { filter { includeTestsMatching("com.friends.core.StorageTest") includeTestsMatching("com.friends.core.FriendsTest") + includeTestsMatching("com.friends.core.ModerationTest") } jvmArgs("--enable-native-access=ALL-UNNAMED") // 3.21.0.1 has no Apple Silicon native (Linux x86_64/aarch64 are fine); 3.7.2 falls back to pure Java. diff --git a/core/src/main/java/com/friends/core/ConfigUpgrade.java b/core/src/main/java/com/friends/core/ConfigUpgrade.java index 35f12ab..1c7dfbf 100644 --- a/core/src/main/java/com/friends/core/ConfigUpgrade.java +++ b/core/src/main/java/com/friends/core/ConfigUpgrade.java @@ -2,46 +2,89 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardOpenOption; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; 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. + * Keeps a server's config.yml current across upgrades: adds, with their comments, what the bundled config.yml has and + * theirs lacks. Whole top-level sections go at the end of the file; keys one level down (e.g. + * {@code private-messages.rate-limit}) go at the end of their section, indented like the user's. Nothing they wrote + * is rewritten or reordered (their comments and line endings stay); keys missing deeper down 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 static final Pattern CHILD_KEY = Pattern.compile("^( +)([A-Za-z0-9_-]+):"); private ConfigUpgrade() {} - /** Appends what {@code file} lacks from {@code bundled}; returns the added section names (empty: unchanged). */ + /** Their section: where it ends (offset after its last indented line), its keys' indent, which keys it has. */ + record Block(int end, boolean scalar, int indent, Set keys) {} + + /** Adds what {@code file} lacks from {@code bundled}; returns what was added, e.g. "presence" (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(); + String nl = current.contains("\r\n") ? "\r\n" : "\n"; + Map theirs = blocks(current); List names = new ArrayList<>(); - sections(bundled).forEach((key, block) -> { - if (present.contains(key)) return; - names.add(key); - added.append('\n').append(block); + TreeMap inserts = new TreeMap<>(); // offset in current -> text to insert there + StringBuilder appended = new StringBuilder(); + sections(bundled).forEach((key, section) -> { + Block mine = theirs.get(key); + if (mine == null) { + names.add(key); + appended.append('\n').append(section); + return; + } + if (mine.scalar()) return; // "key: value" or "key: {...}": nothing can go under it + children(section).forEach((child, text) -> { + if (mine.keys().contains(child)) return; + names.add(key + "." + child); + inserts.computeIfAbsent(mine.end(), _ -> new StringBuilder()).append(reindent(text, mine.indent())); + }); }); if (names.isEmpty()) return names; - String text = (current.isEmpty() || current.endsWith("\n") ? "" : "\n") + added; - Files.writeString(file, text, StandardCharsets.UTF_8, StandardOpenOption.APPEND); + + StringBuilder text = new StringBuilder(current); + for (var e : inserts.descendingMap().entrySet()) { // from the end, so earlier offsets stay valid + int at = e.getKey(); + boolean lineStart = at == 0 || text.charAt(at - 1) == '\n'; + text.insert(at, (lineStart ? "" : nl) + e.getValue().toString().replace("\n", nl)); + } + if (!appended.isEmpty()) { + if (!text.isEmpty() && text.charAt(text.length() - 1) != '\n') text.append(nl); + text.append(appended.toString().replace("\n", nl)); + } + write(file, text.toString()); return names; } + /** + * Replaces the file in one step (a crash mid-write must not leave a truncated config). Writes through symbolic + * links (some hosts share one config.yml between proxies). + */ + private static void write(Path file, String text) throws IOException { + Path target = file.toRealPath(); + Path tmp = target.resolveSibling(target.getFileName() + ".tmp"); + Files.writeString(tmp, text, StandardCharsets.UTF_8); + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } + /** Each top-level key's lines, with the column-0 comments just above it. */ static Map sections(String yaml) { Map sections = new LinkedHashMap<>(); @@ -65,6 +108,80 @@ static Map sections(String yaml) { return out; } + /** A bundled section's keys one level down (indented 2), each with the indented comments just above it. */ + static Map children(String section) { + Map children = new LinkedHashMap<>(); + StringBuilder pending = new StringBuilder(); + StringBuilder current = null; + for (String line : section.lines().toList()) { + Matcher key = CHILD_KEY.matcher(line); + if (line.isBlank()) { + pending.append('\n'); + } else if (!line.startsWith(" ")) { // the section's own key, or the comments above it + pending.setLength(0); + } else if (key.find() && key.group(1).length() == 2) { + current = new StringBuilder(strip(pending)).append(line).append('\n'); + pending.setLength(0); + children.put(key.group(2), current); + } else if (line.stripLeading().startsWith("#")) { + pending.append(line).append('\n'); + } else if (current != null) { // deeper: still the current key + current.append(pending).append(line).append('\n'); + pending.setLength(0); + } + } + Map out = new LinkedHashMap<>(); + children.forEach((k, v) -> out.put(k, v.toString())); + return out; + } + + /** The user's top-level sections, read line by line with their offsets ('\r' kept out of the matching). */ + static Map blocks(String yaml) { + Map blocks = new LinkedHashMap<>(); + String key = null; + int end = 0; + int indent = -1; + boolean scalar = false; + Set keys = new HashSet<>(); + for (int pos = 0; pos < yaml.length(); ) { + int eol = yaml.indexOf('\n', pos); + int next = eol < 0 ? yaml.length() : eol + 1; + String line = yaml.substring(pos, eol < 0 ? yaml.length() : eol).replace("\r", ""); + Matcher top = TOP_LEVEL_KEY.matcher(line); + if (top.find()) { + if (key != null) blocks.putIfAbsent(key, new Block(end, scalar, indent < 0 ? 2 : indent, keys)); + key = top.group(1); + end = next; + indent = -1; + String value = line.substring(top.end()).strip(); + scalar = !value.isEmpty() && !value.startsWith("#"); + keys = new HashSet<>(); + } else if (key != null && line.startsWith(" ") && !line.isBlank()) { + end = next; + if (!line.stripLeading().startsWith("#")) { + int lead = line.length() - line.stripLeading().length(); + if (indent < 0) indent = lead; + Matcher child = CHILD_KEY.matcher(line); + if (lead == indent && child.find()) keys.add(child.group(2)); + } + } + pos = next; + } + if (key != null) blocks.putIfAbsent(key, new Block(end, scalar, indent < 0 ? 2 : indent, keys)); + return blocks; + } + + /** Bundled lines are indented by 2 per level: use the user's step instead. */ + private static String reindent(String text, int step) { + if (step == 2) return text; + StringBuilder out = new StringBuilder(); + for (String line : text.lines().toList()) { + int lead = line.length() - line.stripLeading().length(); + out.append(" ".repeat(lead / 2 * step)).append(line.stripLeading()).append('\n'); + } + return out.toString(); + } + /** 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/FriendCommand.java b/core/src/main/java/com/friends/core/FriendCommand.java index 9f5920a..bf0459a 100644 --- a/core/src/main/java/com/friends/core/FriendCommand.java +++ b/core/src/main/java/com/friends/core/FriendCommand.java @@ -19,7 +19,7 @@ */ public final class FriendCommand { private static final List SUBCOMMANDS = List.of("add", "accept", "deny", "list", "requests", "remove", - "best", "nickname", "removeall", "notifications", "status", "help"); + "best", "nickname", "removeall", "notifications", "status", "ignore", "unignore", "help"); private static final int MAX_SUGGESTIONS = 100; private final Friends friends; @@ -66,6 +66,8 @@ 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); + case "ignore" -> arg == null ? friends.ignoreList(s) : friends.ignore(s, arg); + case "unignore" -> arg == null ? usage(s, "/f unignore ") : friends.unignore(s, arg); default -> args.length == 1 ? friends.add(s, Target.named(args[0])) : friends.help(s, privateMessages); // "/f Steve" = "/f add Steve" }; return result.exceptionally(_ -> { @@ -123,6 +125,12 @@ yield filter(friends.onlineNames().stream() .filter(n -> !n.equalsIgnoreCase(s.name()) && !friendNames.contains(n.toLowerCase(Locale.ROOT))), prefix); } case "accept", "deny", "decline" -> filter(friends.requesterNames(s.id()).stream(), prefix); + case "ignore" -> { + var ignored = friends.ignoredNames(s.id()).stream().map(n -> n.toLowerCase(Locale.ROOT)).collect(java.util.stream.Collectors.toSet()); + yield filter(friends.onlineNames().stream() + .filter(n -> !n.equalsIgnoreCase(s.name()) && !ignored.contains(n.toLowerCase(Locale.ROOT))), prefix); + } + case "unignore" -> filter(friends.ignoredNames(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); diff --git a/core/src/main/java/com/friends/core/Friends.java b/core/src/main/java/com/friends/core/Friends.java index c6a07f6..7721c23 100644 --- a/core/src/main/java/com/friends/core/Friends.java +++ b/core/src/main/java/com/friends/core/Friends.java @@ -6,10 +6,12 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.ArrayDeque; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -51,16 +53,42 @@ public final class Friends implements Network.Listener { static final class Profile { final Audience audience; final Map friends = new ConcurrentHashMap<>(); + final Map ignored = new ConcurrentHashMap<>(); // players they ignore: id -> name volatile boolean notifications; volatile Target replyTo; // who /r answers: the last private message sent or received (id and name) + // Rate limit and AFK auto-reply cooldowns live here, guarded by this profile: they go with it at disconnect. + // ponytail: no cache library needed, both are bounded (by the limit, and by one entry per friend). + private final ArrayDeque sent = new ArrayDeque<>(); // their private messages within the window + private final Map afkReplied = new HashMap<>(); // friend -> when their AFK reply was shown Profile(Audience audience, Storage.Loaded data) { this.audience = audience; this.notifications = data.notifications(); data.friends().forEach(f -> friends.put(f.id(), f)); + ignored.putAll(data.ignored()); + } + + /** Counts a private message sent {@code now}, or returns how long to wait if that would exceed {@code limit}. */ + synchronized Duration send(Instant now, Settings.RateLimit limit) { + if (!limit.enabled()) return Duration.ZERO; + while (!sent.isEmpty() && !now.isBefore(sent.peekFirst().plus(limit.window()))) sent.pollFirst(); + if (sent.size() >= limit.messages()) return Duration.between(now, sent.peekFirst().plus(limit.window())); + sent.addLast(now); + return Duration.ZERO; + } + + /** Whether to show {@code friend}'s AFK auto-reply now: once per {@link #AFK_REPLY_EVERY} per friend. */ + synchronized boolean afkReplyDue(UUID friend, Instant now) { + Instant last = afkReplied.get(friend); + if (last != null && now.isBefore(last.plus(AFK_REPLY_EVERY))) return false; + afkReplied.put(friend, now); + return true; } } + /** How often a sender sees the same AFK friend's auto-reply. */ + static final Duration AFK_REPLY_EVERY = Duration.ofMinutes(5); + private interface SqlCall { T call() throws SQLException; } @@ -77,6 +105,7 @@ private interface SqlRun { private final Clock clock; private final Duration requestExpiry; private final int maxFriends; + private final Settings.RateLimit rateLimit; private final Activities activities; private final Hooks hooks; private final Executor async; // asks plugins (hooks) off the database thread @@ -103,7 +132,8 @@ 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, Activities activities, Hooks hooks, Logger log) { + Duration requestExpiry, int maxFriends, Settings.RateLimit rateLimit, Activities activities, Hooks hooks, + Logger log) { this.storage = storage; this.platform = platform; this.network = network; @@ -112,6 +142,7 @@ public Friends(Storage storage, Platform platform, Network network, Executor db, this.clock = clock; this.requestExpiry = requestExpiry; this.maxFriends = maxFriends; + this.rateLimit = rateLimit; this.activities = activities; this.hooks = hooks; this.async = async; @@ -216,7 +247,9 @@ private void applyLocked(Event event) { // 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) + // On another proxy (or just left); or ignoring the sender, whose proxy may not have heard of it yet + // (the ignore removed them as friends, and that Unfriended event can still be on its way there). + if (p == null || p.ignored.containsKey(from.id())) return; 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)); @@ -281,7 +314,9 @@ 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(), activities.match(p.server())))); + PlayerActivity activity = activities.match(p.server()); + emit(new Event.Joined(p.id(), new Presence(p.name(), p.prefix(), proxy, p.server(), data.status(), activity))); + if (activity != null) hooks.activityChanged(row(p), null, activity); return completedFuture(null); } } @@ -295,7 +330,7 @@ public void greet(Online p) { /** * 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. + * if neither changed; plugins hear of a new activity ({@link Hooks#activityChanged}). */ public void moved(Online p) { PlayerActivity activity = activities.match(p.server()); // outside the lock: regexes @@ -303,7 +338,9 @@ public void moved(Online p) { Presence old = presence.get(p.id()); 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)); + if (now.equals(old)) return; + emit(new Event.Updated(p.id(), now)); + if (!Objects.equals(old.activity(), activity)) hooks.activityChanged(row(p), old.activity(), activity); } } @@ -313,8 +350,10 @@ public void disconnect(Online p) { // Only the session that loaded the profile may drop it (kick-existing-players can reorder events). if (profile == null || profile.audience != p.audience() || !profiles.remove(p.id(), profile)) return; Instant now = clock.instant(); + Presence was = presence.get(p.id()); write(() -> storage.touch(p.id(), now)); emit(new Event.Left(p.id(), proxy, now)); + if (was != null && was.proxy().equals(proxy) && was.activity() != null) hooks.activityChanged(row(p), was.activity(), null); } } @@ -362,9 +401,14 @@ public CompletableFuture add(Online s, Target target) { Profile me = profiles.get(s.id()); if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); if (target.is(s.id(), s.name())) return reply(s, Messages.addSelf(), Result.SELF); - return resolve(target).thenCompose(found -> found.isEmpty() - ? reply(s, Messages.notFound(target.label()), Result.PLAYER_NOT_FOUND) - : friendCount(found.get().id()).thenComposeAsync(count -> add(s, me, found.get(), count, false), async)); + return resolve(target).thenCompose(found -> { + if (found.isEmpty()) return reply(s, Messages.notFound(target.label()), Result.PLAYER_NOT_FOUND); + PlayerRow t = found.get(); + if (me.ignored.containsKey(t.id())) return reply(s, Messages.youIgnore(Messages.name(t), t.name()), Result.IGNORED); + return ignoring(t.id(), s.id()).thenCompose(ignored -> ignored + ? reply(s, Messages.cantRequest(Messages.name(t)), Result.IGNORED) + : friendCount(t.id()).thenComposeAsync(count -> add(s, me, t, count, false), async)); + }); } /** All checks and the send in one locked step, asking plugins in between ({@code vetted}: they agreed). */ @@ -372,6 +416,7 @@ private CompletableFuture add(Online s, Profile me, PlayerRow target, in Request theirs; synchronized (lock) { Component targetName = Messages.name(target); + if (me.ignored.containsKey(target.id())) return reply(s, Messages.youIgnore(targetName, target.name()), Result.IGNORED); // meanwhile if (me.friends.containsKey(target.id())) return reply(s, Messages.alreadyFriends(targetName), Result.ALREADY_FRIENDS); theirs = valid(requests.get(new Request.Key(target.id(), s.id()))); if (theirs == null) { @@ -402,6 +447,8 @@ public CompletableFuture accept(Online s, Target target) { private CompletableFuture accept(Online s, Profile me, Request r) { PlayerRow other = r.from(); + // Ignoring drops their requests: one still here crossed the ignore on another proxy. + if (me.ignored.containsKey(other.id())) return reply(s, Messages.youIgnore(Messages.name(other), other.name()), Result.IGNORED); if (me.friends.size() >= maxFriends) return reply(s, Messages.limitSelf(maxFriends), Result.LIMIT_REACHED); return friendCount(other.id()).thenComposeAsync(count -> { if (count >= maxFriends) return reply(s, Messages.limitOther(Messages.name(other)), Result.TARGET_LIMIT_REACHED); @@ -530,17 +577,25 @@ private CompletableFuture message(Online s, Target target, String text, 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); + // Counted before plugins are asked: a listener that cancels (to deliver it itself) is still spared the spam. + Duration wait = me.send(clock.instant(), rateLimit); + if (wait.isPositive()) return reply(s, Messages.tooFast(wait), Result.RATE_LIMITED); 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); + Presence now = visible(f.id()); + if (now == 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); + // Delivered all the same; the sender's own chat (also for API sends): it's the friend answering, not a reply. + if (now.status() == Status.AWAY && me.afkReplyDue(f.id(), clock.instant())) { + me.audience.sendMessage(Messages.afkReply(Messages.friendName(f), f.name())); + } return completedFuture(Result.SUCCESS); } @@ -552,6 +607,62 @@ public CompletableFuture replyMessage(Online s, String text) { return to == null ? reply(s, Messages.nobodyToReply(), Result.INVALID_ARGUMENT) : message(s, to, text, "/r "); } + /** + * {@code /f ignore } (anyone who has joined): blocks their private messages and friend requests. Silently + * ends a friendship with them (neither side is told; plugins get {@link Hooks#unfriended}, but can't veto it) and + * drops pending requests either way. Saved in the database, which every proxy shares. + */ + public CompletableFuture ignore(Online s, Target target) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + if (target.is(s.id(), s.name())) return reply(s, Messages.ignoreSelf(), Result.SELF); + return resolve(target).thenCompose(found -> found.isEmpty() + ? reply(s, Messages.notFound(target.label()), Result.PLAYER_NOT_FOUND) + : ignore(s, me, found.get())); + } + + private CompletableFuture ignore(Online s, Profile me, PlayerRow target) { + PlayerRow mine = row(s); + synchronized (lock) { + Component name = Messages.name(target); + if (me.ignored.putIfAbsent(target.id(), target.name()) != null) return reply(s, Messages.alreadyIgnoring(name), Result.SUCCESS); + Instant now = clock.instant(); + write(() -> storage.addIgnore(s.id(), target.id(), now)); + Friend f = me.friends.get(target.id()); + if (f != null) { // like /f remove, minus the veto and the reply + write(() -> storage.removeFriendships(s.id(), List.of(f.id()))); + emit(new Event.Unfriended(s.id(), List.of(f.id()))); + hooks.unfriended(mine, f); + } + for (Request.Key key : List.of(new Request.Key(target.id(), s.id()), new Request.Key(s.id(), target.id()))) { + if (requests.remove(key) != null) emit(new Event.RequestDenied(key.from(), key.to())); + } + return reply(s, Messages.ignored(name, f != null), Result.SUCCESS); + } + } + + public CompletableFuture unignore(Online s, Target target) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + synchronized (lock) { + var entry = me.ignored.entrySet().stream().filter(e -> target.is(e.getKey(), e.getValue())).findFirst().orElse(null); + if (entry == null || !me.ignored.remove(entry.getKey(), entry.getValue())) { + return reply(s, Messages.notIgnoring(target.label()), Result.INVALID_ARGUMENT); + } + UUID id = entry.getKey(); + write(() -> storage.removeIgnore(s.id(), id)); + return reply(s, Messages.unignored(Messages.name(null, entry.getValue())), Result.SUCCESS); + } + } + + /** {@code /f ignore} without a name. */ + public CompletableFuture ignoreList(Online s) { + Profile me = profiles.get(s.id()); + if (me == null) return reply(s, Messages.notLoaded(), Result.NOT_LOADED); + List names = me.ignored.values().stream().sorted(String.CASE_INSENSITIVE_ORDER).toList(); + return reply(s, Messages.ignoreList(names), Result.SUCCESS); + } + /** Online anywhere and not appearing offline, else null. */ private Presence visible(UUID id) { Presence p = presence.get(id); @@ -606,6 +717,11 @@ public List onlineFriendNames(UUID id) { return p == null ? List.of() : p.friends.values().stream().filter(f -> visible(f.id()) != null).map(Friend::name).toList(); } + public List ignoredNames(UUID id) { + Profile p = profiles.get(id); + return p == null ? List.of() : List.copyOf(p.ignored.values()); + } + public List requesterNames(UUID id) { return incoming(id).map(r -> r.from().name()).toList(); } @@ -728,6 +844,12 @@ private CompletableFuture> resolve(Target target) { return db(() -> storage.player(name)); } + /** Whether {@code player} ignores {@code other}: from memory if {@code player} is loaded here, else the database. */ + private CompletableFuture ignoring(UUID player, UUID other) { + Profile p = profiles.get(player); + return p != null ? completedFuture(p.ignored.containsKey(other)) : db(() -> storage.ignores(player, other)); + } + private CompletableFuture friendCount(UUID id) { Profile p = profiles.get(id); return p != null ? completedFuture(p.friends.size()) : db(() -> storage.countFriends(id)); diff --git a/core/src/main/java/com/friends/core/FriendsApi.java b/core/src/main/java/com/friends/core/FriendsApi.java index e3fc411..ce3ed47 100644 --- a/core/src/main/java/com/friends/core/FriendsApi.java +++ b/core/src/main/java/com/friends/core/FriendsApi.java @@ -113,6 +113,12 @@ public CompletableFuture setStatus(UUID player, Status status) { return act(player, status, s -> friends.status(s, status)); } + @Override + public CompletableFuture sendMessage(UUID sender, UUID receiver, String message) { + if (message == null) return completedFuture(Result.INVALID_ARGUMENT); + return act(sender, receiver, s -> friends.message(s, Target.of(receiver), message)); + } + /** * Runs {@code action} as {@code player}'s command, without the chat reply. The whole action runs on {@code async} * (never on the caller's thread, which may be the server thread events must not be fired from). diff --git a/core/src/main/java/com/friends/core/FriendsRuntime.java b/core/src/main/java/com/friends/core/FriendsRuntime.java index dee4492..690bc3d 100644 --- a/core/src/main/java/com/friends/core/FriendsRuntime.java +++ b/core/src/main/java/com/friends/core/FriendsRuntime.java @@ -10,6 +10,7 @@ import org.slf4j.Logger; import com.friends.api.FriendsProvider; +import com.friends.api.PlayerActivity; import com.friends.api.Status; import com.friends.core.Storage.PlayerRow; @@ -40,10 +41,10 @@ public FriendsRuntime(Settings settings, Storage storage, Network network, Platf 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"); + log.info("Friends: {} presence rule(s); private messages {} (rate limit: {})", settings.activities().size(), + settings.privateMessages() ? "on /msg and /r" : "off", settings.rateLimit()); this.friends = new Friends(storage, platform, network, db, async, clock, settings.requestExpiry(), - settings.maxFriends(), settings.activities(), inOrder(hooks), log); + settings.maxFriends(), settings.rateLimit(), settings.activities(), inOrder(hooks), log); this.command = new FriendCommand(friends, settings.privateMessages()); this.api = new FriendsApi(friends, platform, async); friends.start(); @@ -94,6 +95,7 @@ private Hooks inOrder(Hooks hooks) { @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); } + @Override public void activityChanged(PlayerRow player, PlayerActivity from, PlayerActivity to) { done.execute(() -> hooks.activityChanged(player, from, to)); } }; } } diff --git a/core/src/main/java/com/friends/core/Hooks.java b/core/src/main/java/com/friends/core/Hooks.java index eb7780c..143c53d 100644 --- a/core/src/main/java/com/friends/core/Hooks.java +++ b/core/src/main/java/com/friends/core/Hooks.java @@ -2,6 +2,7 @@ import java.time.Instant; +import com.friends.api.PlayerActivity; import com.friends.api.Status; import com.friends.core.Storage.PlayerRow; @@ -46,5 +47,11 @@ default boolean statusChanging(PlayerRow player, Status from, Status to) { return true; } + /** + * What a player on this proxy is doing changed (null: nothing a {@code presence.rules} rule knows): they joined, + * switched server or left. + */ + default void activityChanged(PlayerRow player, PlayerActivity from, PlayerActivity to) {} + Hooks NONE = new Hooks() {}; } diff --git a/core/src/main/java/com/friends/core/Messages.java b/core/src/main/java/com/friends/core/Messages.java index 9785dba..07acc9d 100644 --- a/core/src/main/java/com/friends/core/Messages.java +++ b/core/src/main/java/com/friends/core/Messages.java @@ -97,6 +97,8 @@ private static Component button(String label, NamedTextColor color, String comma 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("/f ignore [player] - Ignore a player (alone: list who you ignore)"), + mm("/f unignore \\ - Stop ignoring a player"), 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( @@ -297,12 +299,78 @@ static Component offline(Component friend, Instant lastSeen, Instant now) { Placeholder.unparsed("seen", LastSeen.format(lastSeen, now)))); } + /** {@code wait}: until the oldest message in the window leaves it (rounded up to whole seconds). */ + static Component tooFast(Duration wait) { + long seconds = Math.max(1, (wait.toMillis() + 999) / 1000); + return box(mm("You're sending messages too fast! Try again in second.", + Placeholder.unparsed("n", Long.toString(seconds)), Placeholder.unparsed("s", seconds == 1 ? "" : "s"))); + } + + private static final Component AFK_TEXT = text("I'm currently AFK, and will answer when I am available.", GRAY, TextDecoration.ITALIC); + private static final Component AUTO_REPLY = text(" (auto-reply)", DARK_GRAY); + + /** What a friend whose status is Away "answers": a private message line, marked as automatic. */ + static Component afkReply(Component name, String username) { + return text().append(FROM).append(name).append(COLON).append(AFK_TEXT).append(AUTO_REPLY) + .clickEvent(ClickEvent.suggestCommand("/msg " + username + " ")) + .hoverEvent(HoverEvent.showText(text("Click to reply", GRAY))) + .build(); + } + private static final Component NOBODY_TO_REPLY = box(mm("You have nobody to reply to! Use /msg \\ \\")); static Component nobodyToReply() { return NOBODY_TO_REPLY; } + // --- ignore --- + + private static final Component IGNORE_SELF = box(mm("You can't ignore yourself!")); + + static Component ignoreSelf() { + return IGNORE_SELF; + } + + static Component alreadyIgnoring(Component p) { + return box(mm("You're already ignoring .", player(p))); + } + + static Component ignored(Component p, boolean wasFriend) { + Component done = mm("You're now ignoring : they can't message you or send you friend requests.", player(p)); + return wasFriend ? box(done, mm("They were removed from your friends list (they aren't told).")) : box(done); + } + + static Component notIgnoring(String input) { + return box(mm("You aren't ignoring ''!", Placeholder.unparsed("name", input))); + } + + static Component unignored(Component p) { + return box(mm("You're no longer ignoring .", player(p))); + } + + private static final Component NOBODY_IGNORED = box(mm("You aren't ignoring anyone. To block a player: /f ignore \\")); + + /** Each name suggests (doesn't run) {@code /f unignore}: one click shouldn't undo an ignore. */ + static Component ignoreList(List names) { + if (names.isEmpty()) return NOBODY_IGNORED; + var line = text(); + for (int i = 0; i < names.size(); i++) { + if (i > 0) line.append(text(", ", DARK_GRAY)); + line.append(text(names.get(i), GRAY).clickEvent(ClickEvent.suggestCommand("/f unignore " + names.get(i))) + .hoverEvent(HoverEvent.showText(text("Click to unignore", GRAY)))); + } + return box(mm("Ignored players ():", Placeholder.unparsed("count", Integer.toString(names.size()))), line.build()); + } + + static Component youIgnore(Component p, String username) { + return box(mm("You're ignoring ! Use /f unignore first.", player(p), + Placeholder.unparsed("name", username))); + } + + static Component cantRequest(Component p) { + return box(mm("You can't send a friend request.", player(p))); + } + // --- notifications --- // Sent to every online friend on each join/leave: plain builders, no parsing (same styling as diff --git a/core/src/main/java/com/friends/core/Settings.java b/core/src/main/java/com/friends/core/Settings.java index 21ef9d2..bd67d48 100644 --- a/core/src/main/java/com/friends/core/Settings.java +++ b/core/src/main/java/com/friends/core/Settings.java @@ -10,13 +10,27 @@ /** 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, - boolean privateMessages, Activities activities, List warnings) { + boolean privateMessages, RateLimit rateLimit, Activities activities, List warnings) { public record Mysql(String host, int port, String database, String user, String password, boolean ssl) {} public record Redis(boolean enabled, String host, int port, String password, int database, boolean ssl, String proxyId, String namespace) {} + /** {@code private-messages.rate-limit}: at most {@code messages} private messages per sender within any {@code window}. */ + public record RateLimit(int messages, Duration window) { + public static final RateLimit NONE = new RateLimit(0, Duration.ZERO); + + public boolean enabled() { + return messages > 0 && window.isPositive(); + } + + @Override + public String toString() { + return enabled() ? messages + " per " + window.toSeconds() + "s" : "off"; + } + } + /** Dotted-path lookups with defaults, backed by the platform's config API. */ public interface Source { String string(String path, String def); @@ -48,7 +62,14 @@ public static Settings read(Source s) { 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)), - s.flag("private-messages.enabled", true), activities, List.copyOf(warnings)); + s.flag("private-messages.enabled", true), rateLimit(s), activities, List.copyOf(warnings)); + } + + /** 0 (or less) for either value turns it off. */ + private static RateLimit rateLimit(Source s) { + int messages = s.number("private-messages.rate-limit.messages", 3); + int seconds = s.number("private-messages.rate-limit.seconds", 3); + return messages > 0 && seconds > 0 ? new RateLimit(messages, Duration.ofSeconds(seconds)) : RateLimit.NONE; } public boolean usesMysql() { diff --git a/core/src/main/java/com/friends/core/Storage.java b/core/src/main/java/com/friends/core/Storage.java index 68ec632..5ac30d8 100644 --- a/core/src/main/java/com/friends/core/Storage.java +++ b/core/src/main/java/com/friends/core/Storage.java @@ -9,8 +9,10 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -26,7 +28,8 @@ public final class Storage implements AutoCloseable { public record PlayerRow(UUID id, String name, String prefix, Instant lastSeen) {} - public record Loaded(boolean notifications, Status status, List friends) {} + /** {@code ignored}: the players they ignore, id to name. */ + public record Loaded(boolean notifications, Status status, List friends, Map ignored) {} private final HikariDataSource ds; private final boolean mysql; @@ -109,6 +112,14 @@ friend CHAR(36) NOT NULL, nickname VARCHAR(16), PRIMARY KEY (player, friend) )"""); + // 0.7.0. Created on first start like the others, so upgrading needs no migration step. + st.execute(""" + CREATE TABLE IF NOT EXISTS friends_ignores ( + player CHAR(36) NOT NULL, + ignored CHAR(36) NOT NULL, + since BIGINT NOT NULL, + PRIMARY KEY (player, ignored) + )"""); } } @@ -181,7 +192,7 @@ private static Optional playerRow(PreparedStatement p) throws SQLExce } } - /** Settings plus the full friend list with each friend's cached name/prefix/last-seen, in two queries. */ + /** Settings, the full friend list with each friend's cached name/prefix/last-seen, and the ignore list: three queries. */ public Loaded load(UUID id) throws SQLException { try (Connection c = ds.getConnection()) { boolean notifications = true; @@ -211,7 +222,20 @@ public Loaded load(UUID id) throws SQLException { } } } - return new Loaded(notifications, status, friends); + Map ignored = new HashMap<>(); + try (PreparedStatement p = c.prepareStatement(""" + SELECT i.ignored, p.name FROM friends_ignores i LEFT JOIN friends_players p ON p.uuid = i.ignored + WHERE i.player = ?""")) { + p.setString(1, id.toString()); + try (ResultSet rs = p.executeQuery()) { + while (rs.next()) { + String other = rs.getString(1); + String name = rs.getString(2); + ignored.put(UUID.fromString(other), name != null ? name : other); + } + } + } + return new Loaded(notifications, status, friends, ignored); } } @@ -275,6 +299,27 @@ public void setStatus(UUID id, Status status) throws SQLException { update("UPDATE friends_players SET status = ? WHERE uuid = ?", status.name(), id.toString()); } + public void addIgnore(UUID player, UUID ignored, Instant since) throws SQLException { + update((mysql ? "INSERT IGNORE" : "INSERT OR IGNORE") + " INTO friends_ignores (player, ignored, since) VALUES (?, ?, ?)", + player.toString(), ignored.toString(), since.toEpochMilli()); + } + + public void removeIgnore(UUID player, UUID ignored) throws SQLException { + update("DELETE FROM friends_ignores WHERE player = ? AND ignored = ?", player.toString(), ignored.toString()); + } + + /** Whether {@code player} ignores {@code other}. */ + public boolean ignores(UUID player, UUID other) throws SQLException { + try (Connection c = ds.getConnection(); + PreparedStatement p = c.prepareStatement("SELECT 1 FROM friends_ignores WHERE player = ? AND ignored = ?")) { + p.setString(1, player.toString()); + p.setString(2, other.toString()); + try (ResultSet rs = p.executeQuery()) { + return rs.next(); + } + } + } + private void update(String sql, Object... args) throws SQLException { try (Connection c = ds.getConnection(); PreparedStatement p = c.prepareStatement(sql)) { for (int i = 0; i < args.length; i++) { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index eb59d2c..f727721 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -37,9 +37,16 @@ redis: # 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. +# FriendMessageEvent to deliver messages themselves. A friend whose /status is away still gets the message, and the +# sender an "I'm currently AFK" auto-reply (once every 5 minutes per friend). Players blocked with /f ignore can't +# message (or friend) whoever ignores them. private-messages: enabled: true + # Anti-spam: at most this many messages per player within this many seconds (0 = no limit). Over the limit the + # message is blocked, and the sender told how long to wait. + rate-limit: + messages: 3 + seconds: 3 # 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 diff --git a/tools/e2e/run.py b/tools/e2e/run.py index 0bd1e2b..c68cb04 100644 --- a/tools/e2e/run.py +++ b/tools/e2e/run.py @@ -40,6 +40,10 @@ def cmd(bot, line, expect, timeout=10): PRESENCE = {"presence.rules": [{"pattern": "LOB.*", "game": "BedWars", "mode": "Solo"}]} +def said_since(bot, mark): + return "\n".join(text for text, _ in bot.messages[mark:]) + + def friend_flow(alice, bob, where): """The same player story on every platform. `where`: how Alice sees Bob online ("— Online" / "— Playing ...").""" cmd(alice, "/f", "Friend Commands:") @@ -60,6 +64,17 @@ def friend_flow(alice, bob, where): mark = alice.mark() cmd(bob, "/r hello back", "To Alice: hello back") alice.wait_for("From Bob: hello back", mark) + # 0.7.0: an away friend still gets the message, the sender an auto-reply; then the rate limit (3 per 3 s). + cmd(bob, "/status away", "Your status is now Away.") + mark = bob.mark() + cmd(alice, "/msg Bob you there?", "From Bob: I'm currently AFK, and will answer when I am available. (auto-reply)") + bob.wait_for("From Alice: you there?", mark) + cmd(bob, "/status online", "Your status is now Online.") + time.sleep(3) # an empty rate-limit window + for n in (1, 2, 3): + cmd(bob, f"/r spam {n}", f"To Alice: spam {n}") + cmd(bob, "/r spam 4", "You're sending messages too fast! Try again in ") + time.sleep(3) cmd(alice, "/f nickname Bob Bobby", "You'll now see Bob as Bobby.") mark = alice.mark() bob.close() @@ -70,6 +85,18 @@ def friend_flow(alice, bob, where): bob.connect() alice.wait_for("Friend > Bobby joined.", mark) time.sleep(1) + # 0.7.0: ignoring silently ends the friendship and blocks messages and requests, until unignored. + mark, bob_mark = alice.mark(), bob.mark() + cmd(alice, "/f ignore Bob", "They were removed from your friends list (they aren't told).") + cmd(bob, "/msg Alice psst", "'Alice' isn't on your friends list!") + cmd(bob, "/f add Alice", "You can't send Alice a friend request.") + cmd(alice, "/f ignore", "Ignored players (1):") + assert "psst" not in said_since(alice, mark) and "Friend request" not in said_since(alice, mark), said_since(alice, mark) + assert "Alice" not in said_since(bob, bob_mark).replace("'Alice' isn't", "").replace("send Alice a", ""), said_since(bob, bob_mark) + cmd(alice, "/f list", "You don't have any friends yet!") + cmd(alice, "/f unignore Bob", "You're no longer ignoring Bob.") + cmd(bob, "/f add Alice", "You sent a friend request to Alice!") + cmd(alice, "/f accept Bob", "You are now friends with Bob") cmd(alice, "/f remove Bob", "You removed Bob from your friends list!") cmd(alice, "/f list", "You don't have any friends yet!") @@ -260,21 +287,25 @@ def scenario_upgrade(jars, kind): srv.stop(stop) with open(config) as f: before = f.read() - assert "presence:" not in before, "not an older config" + assert "rate-limit:" 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") + srv.wait_log(r"added the new settings \[.+\] to config.yml") # 0.5.0: whole sections; 0.6.0: rate-limit 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" + kept = iter(after.splitlines()) + assert all(line in kept for line in before.splitlines()), "every line of the old config.yml is kept, in order" + assert "\npresence:\n" in after and "\n rate-limit:\n messages: 3\n" in after, after 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) + cmd(alice, "/f ignore Bob", "You're now ignoring Bob") # the new table, created in the old database + cmd(bob, "/msg Alice hi", "'Alice' isn't on your friends list!") for b in (alice, bob): b.close() check_clean(srv) diff --git a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java index 298e840..09229be 100644 --- a/velocity/src/main/java/com/friends/velocity/VelocityHooks.java +++ b/velocity/src/main/java/com/friends/velocity/VelocityHooks.java @@ -4,6 +4,7 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Function; +import com.friends.api.PlayerActivity; import com.friends.api.Status; import com.friends.api.velocity.FriendAddEvent; import com.friends.api.velocity.FriendAddedEvent; @@ -12,6 +13,7 @@ import com.friends.api.velocity.FriendRemovedEvent; import com.friends.api.velocity.FriendRequestSendEvent; import com.friends.api.velocity.FriendStatusChangeEvent; +import com.friends.api.velocity.PlayerActivityChangeEvent; import com.friends.core.Friend; import com.friends.core.Hooks; import com.friends.core.Storage.PlayerRow; @@ -61,6 +63,11 @@ public String messaging(PlayerRow from, PlayerRow to, String message) { return allowed(event) ? event.getMessage() : null; } + @Override + public void activityChanged(PlayerRow player, PlayerActivity from, PlayerActivity to) { + fire.apply(new PlayerActivityChangeEvent(player.id(), player.name(), from, to)).join(); + } + /** Waits for every listener, then reads their verdict. */ private boolean allowed(ResultedEvent event) { fire.apply(event).join();