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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ SQLite driver: Paper 1.12.2's has no Apple Silicon build (Linux x86_64/ARM are f
| `/f nickname <player> [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 <player>` / `/f unignore <player>` | 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`):
Expand All @@ -59,6 +60,12 @@ Private messages (friends only, network-wide; turn off with `private-messages.en
| `/msg <friend> <message>` (also `/tell`, `/w`, `/whisper`) | Message a friend who is online and not appearing offline. Offline: "Bob is offline. (Last seen 3 days ago)". |
| `/r <message>` (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:

```
Expand All @@ -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:
Expand All @@ -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).
Expand All @@ -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
Expand All @@ -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 `<ns>: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
Expand Down
40 changes: 33 additions & 7 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand All @@ -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<PlayerActivity>`, 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()`.
Expand Down
27 changes: 24 additions & 3 deletions api/src/main/java/com/friends/api/FriendsAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ static Optional<FriendsAPI> 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<PlayerActivity> getActivity(UUID player);

Expand Down Expand Up @@ -155,7 +156,8 @@ static Optional<FriendsAPI> 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<Result> sendRequest(UUID sender, UUID target);

Expand All @@ -166,8 +168,8 @@ static Optional<FriendsAPI> 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<Result> acceptRequest(UUID player, UUID sender);

Expand Down Expand Up @@ -222,4 +224,23 @@ static Optional<FriendsAPI> find() {
* {@link Result#INVALID_ARGUMENT}
*/
CompletableFuture<Result> 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.
*
* <p>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<Result> sendMessage(UUID sender, UUID receiver, String message);
}
65 changes: 63 additions & 2 deletions api/src/main/java/com/friends/api/LastSeen.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down Expand Up @@ -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());
}
}
10 changes: 7 additions & 3 deletions api/src/main/java/com/friends/api/Result.java
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>To react to the removal itself, listen to {@link FriendRemovedEvent}.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>To react to the removal itself, listen to {@link FriendRemovedEvent}.
*/
Expand Down
Loading
Loading