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
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,55 @@ SQLite driver: Paper 1.12.2's has no Apple Silicon build (Linux x86_64/ARM are f
| `/f notifications` | Toggle "Friend > X joined./left." messages. |
| `/status [online\|away\|busy\|offline]` | Status shown to friends; `offline` = appear offline (no join/leave messages). |

Private messages (friends only, network-wide; turn off with `private-messages.enabled: false`):

| Command | What it does |
|---|---|
| `/msg <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. |

`/f list` shows each friend's presence:

```
● Player1 — Playing BedWars Solo (green: online; yellow: away; red: busy, with "Away · " / "Busy · ")
● Player2 — In lobby-1 (no presence rule matches the server)
● Player3 — Last seen 17 minutes ago (offline, or appearing offline)
```

Permission `friends.use`: on by default on Paper; on Velocity allowed unless explicitly false; BungeeCord has no
permission check (no permission defaults there).

## Config (`plugins/Friends/config.yml`, `plugins/friends/` on Velocity)

The same file on every platform: `storage.type` `sqlite` or `mysql` (MySQL/MariaDB), `request-expiry-minutes`
(default 5), `max-friends` (default 5000), and the `redis` section (proxies only; ignored on Paper).
(default 5), `max-friends` (default 5000), the `redis` section (proxies only; ignored on Paper),
`private-messages.enabled` (default true) and `presence.rules` (proxies only):

```yaml
presence:
rules: # first match wins; each pattern is a regex matching the whole server name, ignoring case
- pattern: "BW.1S.*"
game: "BedWars"
mode: "Solo"
- pattern: "BW.2S.*"
game: "BedWars"
mode: "Doubles"
- pattern: "SW.*"
game: "SkyWars" # mode is optional: "Playing SkyWars"
```

A rule with an invalid regex or without a game is skipped with a warning at startup; Friends keeps running.

## Upgrading

Replace the jar and restart: no database migration. Sections a new version adds (in 0.6.0: `private-messages` and
`presence`) are appended to your `config.yml` with their comments at startup; nothing you wrote is changed.

- **0.6.0 adds `/msg`, `/tell`, `/w`, `/whisper`, `/r` and `/reply`** (friends only). If another plugin provides them,
set `private-messages.enabled: false`. On Paper, a plugin that declares `/msg` in its plugin.yml (e.g. Essentials)
keeps it and Friends' becomes `/friends:msg` (logged); on the proxies Friends replaces an existing `/msg` (logged).
- **Multi-proxy:** upgrade every proxy together. Older proxies ignore activities (they keep working), but a private
message to a player on a proxy that isn't upgraded yet is lost (that proxy logs "ignoring unknown event type").

## Multiple proxies

Expand All @@ -76,7 +118,8 @@ Use the plugin either on the proxies or on standalone Paper servers, not both.
## API for other plugins

`FriendsAPI` is the same on Paper, Velocity and BungeeCord: friend lists, status, current server and pending
requests, actions (request, accept, deny, remove, best friend, nickname, status) and six events per platform. See
requests, activity, actions (request, accept, deny, remove, best friend, nickname, status) and seven events per
platform (including a cancellable `FriendMessageEvent` to plug in your own chat system). See
[`api/README.md`](api/README.md) for setup, examples and the threading rules.

## How it works
Expand All @@ -94,6 +137,9 @@ requests, actions (request, accept, deny, remove, best friend, nickname, status)
bootstrap, where other plugins' class loaders can see it. It skips SQLite (every Paper bundles its
own; the SQL stays portable to SQLite 3.7.2, Paper 1.8.8's) and Redis, so it is 3.4 MB, and tells Paper 1.20.5+
not to remap it.
- Presence (online, server, status, activity) lives in memory and, with several proxies, in the Redis `<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
(`players`, `friends`, `requests`, `settings`) is not migrated.

Expand Down
30 changes: 28 additions & 2 deletions api/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Friends API

Lets other plugins read friend lists, presence and requests, act on behalf of players and react to friend events.
The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has six events
The same `FriendsAPI` exists on Paper, Velocity and BungeeCord. Each platform also has seven events
(`com.friends.api.bukkit`, `.velocity`, `.bungee`). The API is Java 8 bytecode with no dependencies of its own, so
any plugin can compile against it. Every public type and method has Javadoc (`./gradlew :api:javadoc`).

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.5.0") }
dependencies { compileOnly("com.friends:friends-api:0.6.0") }
```

Then make your plugin load after Friends:
Expand Down Expand Up @@ -52,11 +52,22 @@ List<Friend> list = friends.getFriends(player); // empty unless loaded
boolean pals = friends.areFriends(alice, bob); // at least one of them must be loaded
Optional<Status> status = friends.getStatus(player); // online anywhere: ONLINE, AWAY, BUSY, OFFLINE (appears offline)
Optional<String> server = friends.getServer(player); // backend server name, proxies only (empty on Paper)
Optional<PlayerActivity> doing = friends.getActivity(player); // from presence.rules, proxies only (see below)
List<FriendRequest> in = friends.getIncomingRequests(player);
List<FriendRequest> out = friends.getOutgoingRequests(player);
```

`Friend` gives `getUniqueId()`, `getName()`, `getSince()`, `isBestFriend()`, `getNickname()` and `getLastSeen()`.
`PlayerActivity` gives `getGame()`, `getMode()` (null if the rule has none) and `describe()` (`"Playing BedWars Solo"`):
the first `presence.rules` pattern matching the player's backend server, synchronised across proxies like the server.

For menus, scoreboards and other UIs, `LastSeen` words times like Friends' chat does:

```java
String online = friends.getActivity(id).map(PlayerActivity::describe).orElse("Online");
String offline = LastSeen.format(friend.getLastSeen()); // "Last seen 17 minutes ago"
String ago = LastSeen.ago(friend.getLastSeen(), Instant.now()); // "17 minutes ago", "3 days ago", "just now"
```
`FriendRequest` gives `getSender()`, `getSenderName()`, `getTarget()`, `getTargetName()` and `getExpiresAt()`.

## 4. Load and act (asynchronous)
Expand Down Expand Up @@ -88,10 +99,25 @@ gets no chat reply: the `Result` tells you what happened. The other player is st
| `FriendRemoveEvent` | yes | before a friendship ends, once per friend (`/f removeall` too) |
| `FriendRemovedEvent` | no | after, exactly once |
| `FriendStatusChangeEvent` | yes | before `/status` changes |
| `FriendMessageEvent` | yes | before a private message (`/msg`, `/r`) is delivered; `setMessage` rewrites it |

Pair events have `getPlayerId()` / `getPlayerName()` (the acting player) and `getTargetId()` / `getTargetName()`. The
status event has `getOldStatus()` / `getNewStatus()`. `FriendAddedEvent` also has `getSince()`.

`FriendMessageEvent` (sender = player, recipient = target) has `getMessage()` / `setMessage()`. Cancel it to own the
messaging layer: Friends then delivers nothing, echoes nothing and doesn't change either player's `/r` target, so your
chat plugin can deliver (or block) it its own way, e.g. through your network's chat system:

```java
@Subscribe // Velocity; on BungeeCord/Paper: @EventHandler and e.setCancelled(true)
public void onMessage(FriendMessageEvent e) {
e.setResult(ResultedEvent.GenericResult.denied());
myChat.whisper(e.getPlayerId(), e.getTargetId(), e.getMessage());
}
```

The text is plain: Friends never parses colours or formatting in it.

Paper (`com.friends.api.bukkit`, asynchronous Bukkit events):

```java
Expand Down
10 changes: 10 additions & 0 deletions api/src/main/java/com/friends/api/FriendsAPI.java
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ static Optional<FriendsAPI> find() {
*/
Optional<String> getServer(UUID player);

/**
* What a player online anywhere on the network is doing, from the first {@code presence.rules} pattern matching
* their backend server's name (proxies only). Synchronised across proxies, like {@link #getServer}.
*
* @param player a player's UUID
* @return e.g. BedWars / Solo; empty on Paper, while connecting, if no rule matches their server, if offline, or
* if they appear offline
*/
Optional<PlayerActivity> getActivity(UUID player);

/**
* Pending requests the player received (network-wide).
*
Expand Down
52 changes: 52 additions & 0 deletions api/src/main/java/com/friends/api/LastSeen.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.friends.api;

import java.time.Duration;
import java.time.Instant;

/**
* Human-readable "last seen" times, as Friends shows them in chat; for menus, scoreboards and other plugins' UIs.
* Example: {@code LastSeen.format(friend.getLastSeen())} gives {@code "Last seen 17 minutes ago"}.
*/
public final class LastSeen {
private static final long[] SIZES = {60, 60, 24, 30, 12};
private static final String[] UNITS = {"minute", "hour", "day", "month", "year"};

private LastSeen() {}

/**
* How long ago {@code then} was, in its largest whole unit (a month counts 30 days).
*
* @param then a past instant
* @param now the current instant
* @return {@code "just now"} under a minute (or for a future {@code then}), else e.g. {@code "1 minute ago"},
* {@code "17 minutes ago"}, {@code "3 days ago"}, {@code "2 years ago"}
*/
public static String ago(Instant then, Instant now) {
long n = Math.max(0, Duration.between(then, now).getSeconds());
if (n < 60) return "just now";
int unit = -1;
while (unit + 1 < UNITS.length && n >= SIZES[unit + 1]) n /= SIZES[++unit];
return n + " " + UNITS[unit] + (n == 1 ? "" : "s") + " ago";
}

/**
* {@link #ago} as a sentence.
*
* @param then a past instant
* @param now the current instant
* @return e.g. {@code "Last seen 17 minutes ago"} or {@code "Last seen just now"}
*/
public static String format(Instant then, Instant now) {
return "Last seen " + ago(then, now);
}

/**
* {@link #format(Instant, Instant)} as of now.
*
* @param then a past instant, such as {@link Friend#getLastSeen()}
* @return e.g. {@code "Last seen 3 days ago"}
*/
public static String format(Instant then) {
return format(then, Instant.now());
}
}
69 changes: 69 additions & 0 deletions api/src/main/java/com/friends/api/PlayerActivity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.friends.api;

import java.util.Objects;

/**
* What a player is doing, derived from the backend server they are on ({@code presence.rules} in Friends' config: a
* server-name pattern maps to a game and a mode). Generic on purpose: Friends knows no game, only these two labels.
* Immutable, like every Friends model: it is shared by every reader and synchronised across proxies.
*/
public final class PlayerActivity {
private final String game;
private final String mode;

/**
* Created by Friends from its config; plugins have no reason to build one except in tests.
*
* @param game the game, e.g. {@code "BedWars"}; never null
* @param mode the mode, e.g. {@code "Solo"}, or null if the rule has none
*/
public PlayerActivity(String game, String mode) {
this.game = Objects.requireNonNull(game, "game");
this.mode = mode;
}

/**
* The game.
*
* @return e.g. {@code "BedWars"}, never null
*/
public String getGame() {
return game;
}

/**
* The mode within the game.
*
* @return e.g. {@code "Solo"}, or null if none
*/
public String getMode() {
return mode;
}

/**
* How Friends shows it in chat.
*
* @return e.g. {@code "Playing BedWars Solo"}, or {@code "Playing BedWars"} without a mode
*/
public String describe() {
return mode == null ? "Playing " + game : "Playing " + game + " " + mode;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PlayerActivity)) return false;
PlayerActivity a = (PlayerActivity) o;
return game.equals(a.game) && Objects.equals(mode, a.mode);
}

@Override
public int hashCode() {
return Objects.hash(game, mode);
}

@Override
public String toString() {
return "PlayerActivity{game=" + game + ", mode=" + mode + "}";
}
}
4 changes: 3 additions & 1 deletion api/src/main/java/com/friends/api/Result.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ public enum Result {
/** A plugin cancelled the action's event. */
CANCELLED,
/** A database error (Friends logs it). */
ERROR;
ERROR,
/** The target is offline, or appearing offline (private messages). Last, so earlier ordinals never move. */
NOT_ONLINE;

/**
* Whether the action happened.
Expand Down
92 changes: 92 additions & 0 deletions api/src/main/java/com/friends/api/bukkit/FriendMessageEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.friends.api.bukkit;

import java.util.Objects;
import java.util.UUID;

import org.bukkit.event.Cancellable;
import org.bukkit.event.HandlerList;

/**
* {@code player} is about to send {@code target}, one of their friends, a private message ({@code /msg} or
* {@code /r}). Fired once, before anything is delivered.
*
* <p>Cancel it to take over: Friends then delivers nothing, echoes nothing to the sender and doesn't update either
* player's {@code /r} target, so a chat plugin can handle the messaging layer itself (or a moderation plugin can
* block it, explaining why). Or change the text with {@link #setMessage} (e.g. a chat filter).
*/
public final class FriendMessageEvent extends FriendEvent implements Cancellable {
private static final HandlerList HANDLERS = new HandlerList();
private String message;
private boolean cancelled;

/**
* Created by Friends.
*
* @param playerId the sender
* @param playerName their name
* @param targetId the recipient
* @param targetName their name
* @param message what the sender typed
*/
public FriendMessageEvent(UUID playerId, String playerName, UUID targetId, String targetName, String message) {
super(playerId, playerName, targetId, targetName);
this.message = message;
}

/**
* The text to deliver (plain text: never parsed for colours or formatting).
*
* @return never null
*/
public String getMessage() {
return message;
}

/**
* Replaces the text to deliver.
*
* @param message the new text (plain, never parsed); not null
*/
public void setMessage(String message) {
this.message = Objects.requireNonNull(message, "message");
}

/**
* Whether a plugin cancelled the message.
*
* @return true if cancelled
*/
@Override
public boolean isCancelled() {
return cancelled;
}

/**
* Cancels (or un-cancels) the message: Friends then does nothing more with it.
*
* @param cancel true to cancel
*/
@Override
public void setCancelled(boolean cancel) {
cancelled = cancel;
}

/**
* Bukkit's handler list of this event.
*
* @return the handlers
*/
@Override
public HandlerList getHandlers() {
return HANDLERS;
}

/**
* Bukkit's handler list of this event (looked up by Bukkit when registering listeners).
*
* @return the handlers
*/
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
Loading
Loading