feat: Add configuration for server links (#201)

* feat: add server URLs

* refactor: cleanup imports

* fix: only send server links to 1.21 clients

* feat: update server links on reload

* refactor: minor cleanup

* docs: add docs for server links

* fix: protocol version check issue

* Improved ServerUrl#resolve

---------

Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com>
This commit is contained in:
William 2024-06-18 22:42:50 +01:00 committed by GitHub
parent 6f909fbec1
commit 84ae7a9437
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 201 additions and 27 deletions

View File

@ -24,8 +24,8 @@ ext {
repositories { repositories {
mavenCentral() mavenCentral()
maven { url = 'https://repo.william278.net/velocity/' }
maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://repo.papermc.io/repository/maven-public/' }
maven { url = 'https://repo.william278.net/velocity/' }
maven { url = 'https://repo.william278.net/releases/' } maven { url = 'https://repo.william278.net/releases/' }
maven { url = 'https://jitpack.io/' } maven { url = 'https://jitpack.io/' }
maven { url = 'https://repo.minebench.de/' } maven { url = 'https://repo.minebench.de/' }
@ -35,20 +35,20 @@ dependencies {
compileOnly "com.velocitypowered:velocity-api:${velocity_api_version}-SNAPSHOT" compileOnly "com.velocitypowered:velocity-api:${velocity_api_version}-SNAPSHOT"
compileOnly "com.velocitypowered:velocity-proxy:${velocity_api_version}-SNAPSHOT" compileOnly "com.velocitypowered:velocity-proxy:${velocity_api_version}-SNAPSHOT"
compileOnly 'io.netty:netty-codec-http:4.1.111.Final'
compileOnly 'org.projectlombok:lombok:1.18.32'
compileOnly 'net.luckperms:api:5.4'
compileOnly 'io.github.miniplaceholders:miniplaceholders-api:2.0.0'
compileOnly 'net.william278:PAPIProxyBridge:1.5'
compileOnly 'it.unimi.dsi:fastutil:8.5.13'
compileOnly 'net.kyori:adventure-nbt:4.17.0'
implementation 'org.apache.commons:commons-text:1.12.0' implementation 'org.apache.commons:commons-text:1.12.0'
implementation 'net.william278:DesertWell:2.0.4' implementation 'net.william278:DesertWell:2.0.4'
implementation 'net.william278:minedown:1.8.2' implementation 'net.william278:minedown:1.8.2'
implementation 'org.bstats:bstats-velocity:3.0.2' implementation 'org.bstats:bstats-velocity:3.0.2'
implementation 'de.exlll:configlib-yaml:4.5.0' implementation 'de.exlll:configlib-yaml:4.5.0'
compileOnly 'io.netty:netty-codec-http:4.1.111.Final'
compileOnly 'net.luckperms:api:5.4'
compileOnly 'io.github.miniplaceholders:miniplaceholders-api:2.0.0'
compileOnly 'net.william278:PAPIProxyBridge:1.5'
compileOnly 'it.unimi.dsi:fastutil:8.5.13'
compileOnly 'net.kyori:adventure-nbt:4.17.0'
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32' annotationProcessor 'org.projectlombok:lombok:1.18.32'
} }

View File

@ -50,6 +50,16 @@ sort_players: true
remove_spectator_effect: false remove_spectator_effect: false
# Whether to enable the Plugin Message API (allows backend plugins to perform certain operations) # Whether to enable the Plugin Message API (allows backend plugins to perform certain operations)
enable_plugin_message_api: true enable_plugin_message_api: true
# A list of URLs that will be sent to display on player pause menus (Minecraft 1.21+ clients only).
# • Labels can be fully custom or built-in (one of 'bug_report', 'community_guidelines', 'support', 'status',
# 'feedback', 'community', 'website', 'forums', 'news', or 'announcements').
# • If you supply a url with a 'bug_report' label, it will be shown if the player is disconnected.
# • Specify a set of server groups each URL should be sent on. Use '*' to show a URL to all groups.
server_links:
- label: '&#00fb9a&About Velocitab'
url: 'https://william278.net/project/velocitab'
groups:
- '*'
``` ```
</details> </details>
@ -110,4 +120,7 @@ As well as updating the text in the TAB menu, Velocitab supports updating player
Velocitab supports basic header and footer animations by adding multiple frames of animation and setting the update rate to a value greater than 0. Velocitab supports basic header and footer animations by adding multiple frames of animation and setting the update rate to a value greater than 0.
### Placeholders ### Placeholders
You can use various placeholders that will be replaced with values (for example, `%username%`) in your config. Support for PlaceholderAPI is also available through [a bridge library plugin](https://modrinth.com/plugin/papiproxybridge), as is the component-based MiniPlaceholders for users of that plugin with the MiniMessage formatter. See [[Placeholders]] for more information. You can use various placeholders that will be replaced with values (for example, `%username%`) in your config. Support for PlaceholderAPI is also available through [a bridge library plugin](https://modrinth.com/plugin/papiproxybridge), as is the component-based MiniPlaceholders for users of that plugin with the MiniMessage formatter. See [[Placeholders]] for more information.
### Server Links
For Minecraft 1.21+ clients, Velocitab supports specifying a list of URLs that will be sent to display in the player pause menu. See [[Server Links]] for more information.

37
docs/Server-Links.md Normal file
View File

@ -0,0 +1,37 @@
> **Note:** This feature will only apply for users connecting with **Minecraft 1.21+** clients
Velocitab supports sending _Server Links_ to players, which will be displayed in the player pause menu by 1.21+ game clients. This can be useful for linking to your server's website, Discord, or other resources.
## Configuring
Server links are configured with the `server_links` section in your `config.yml` file. A link must have:
* A `url` field; a valid web URL to link to
* A `label` field, which is the text to display for the link. Labels can be::
* Fully formatted custom text. You may include placeholders and formatting valid for your chosen formatter.
* One of the following built-in label strings, which will be localized into the user's client language:
* `bug_report` - Will also be shown on the disconnection error screen.
* `community_guidelines`
* `support`
* `status`
* `feedback`
* `community`
* `website`
* `forums`
* `news`
* `announcements`
* A `groups` field, which is a list of server groups the link should be sent to connecting players on.
* Use `'*'` to show the link to all groups.
### Example section
```yaml
server_links:
- url: 'https://william278.net/project/velocitab'
label: 'website'
groups: ['*']
- url: 'https://william278.net/docs/velocitab'
label: 'Documentation'
groups: ['*']
- url: 'https://github.com/William278/Velocitab/issues'
label: 'bug_report' # This will use the bug report built-in label and also be shown on the player disconnect screen
groups: ['*']
```

View File

@ -3,9 +3,9 @@ javaVersion=17
org.gradle.jvmargs='-Dfile.encoding=UTF-8' org.gradle.jvmargs='-Dfile.encoding=UTF-8'
org.gradle.daemon=true org.gradle.daemon=true
plugin_version=1.6.6 plugin_version=1.7
plugin_archive=velocitab plugin_archive=velocitab
plugin_description=A beautiful and versatile TAB list plugin for Velocity proxies plugin_description=A beautiful and versatile TAB list plugin for Velocity proxies
velocity_api_version=3.3.0 velocity_api_version=3.3.0
velocity_minimum_build=398 velocity_minimum_build=400

BIN
images/server-links.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -77,7 +77,7 @@ public enum Placeholder {
private final TriFunction<String, Velocitab, TabPlayer, String> replacer; private final TriFunction<String, Velocitab, TabPlayer, String> replacer;
private final boolean parameterised; private final boolean parameterised;
private final Pattern pattern; private final Pattern pattern;
private final static Pattern checkPlaceholders = Pattern.compile("%.*?%"); private final static Pattern CHECK_PLACEHOLDERS = Pattern.compile("%.*?%");
private final static String DELIMITER = ":::"; private final static String DELIMITER = ":::";
Placeholder(@NotNull BiFunction<Velocitab, TabPlayer, String> replacer) { Placeholder(@NotNull BiFunction<Velocitab, TabPlayer, String> replacer) {
@ -109,7 +109,6 @@ public enum Placeholder {
public static CompletableFuture<String> replace(@NotNull String format, @NotNull Velocitab plugin, public static CompletableFuture<String> replace(@NotNull String format, @NotNull Velocitab plugin,
@NotNull TabPlayer player) { @NotNull TabPlayer player) {
if (format.equals(DELIMITER)) { if (format.equals(DELIMITER)) {
return CompletableFuture.completedFuture(""); return CompletableFuture.completedFuture("");
} }
@ -119,19 +118,20 @@ public enum Placeholder {
if (placeholder.parameterised) { if (placeholder.parameterised) {
// Replace the placeholder with the result of the replacer function with the parameter // Replace the placeholder with the result of the replacer function with the parameter
format = matcher.replaceAll(matchResult -> format = matcher.replaceAll(matchResult ->
Matcher.quoteReplacement( Matcher.quoteReplacement(placeholder.replacer.apply(
placeholder.replacer.apply(StringUtils.chop(matchResult.group().replace("%" + placeholder.name().toLowerCase(), "")) StringUtils.chop(matchResult.group().replace(
, plugin, player) "%" + placeholder.name().toLowerCase(), ""
)); )), plugin, player
)));
} else { } else {
// Replace the placeholder with the result of the replacer function // Replace the placeholder with the result of the replacer function
format = matcher.replaceAll(matchResult -> Matcher.quoteReplacement(placeholder.replacer.apply(null, plugin, player))); format = matcher.replaceAll(matchResult -> Matcher.quoteReplacement(placeholder.replacer.apply(null, plugin, player)));
} }
} }
final String replaced = format;
if (!checkPlaceholders.matcher(replaced).find()) { final String replaced = format;
if (!CHECK_PLACEHOLDERS.matcher(replaced).find()) {
return CompletableFuture.completedFuture(replaced); return CompletableFuture.completedFuture(replaced);
} }

View File

@ -0,0 +1,90 @@
/*
* This file is part of Velocitab, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.velocitab.config;
import com.google.common.collect.Lists;
import com.velocitypowered.api.util.ServerLink;
import net.william278.velocitab.Velocitab;
import net.william278.velocitab.player.TabPlayer;
import org.jetbrains.annotations.NotNull;
import java.net.URI;
import java.util.*;
import java.util.concurrent.CompletableFuture;
public record ServerUrl(
@NotNull String label,
@NotNull String url,
@NotNull Set<String> groups
) {
public ServerUrl(@NotNull String label, @NotNull String url) {
this(label, url, Set.of("*"));
}
// Resolve the built-in label or format the custom label, then wrap as a Velocity ServerLink
@NotNull
CompletableFuture<ServerLink> getServerLink(@NotNull Velocitab plugin, @NotNull TabPlayer player) {
return getBuiltInLabel().map(
(type) -> CompletableFuture.completedFuture(ServerLink.serverLink(type, url()))
).orElseGet(
() -> Placeholder.replace(label(), plugin, player)
.thenApply(replaced -> plugin.getFormatter().format(replaced, player, plugin))
.thenApply(formatted -> ServerLink.serverLink(formatted, url()))
);
}
@NotNull
public static CompletableFuture<List<ServerLink>> resolve(@NotNull Velocitab plugin, @NotNull TabPlayer player,
@NotNull List<ServerUrl> urls) {
final List<CompletableFuture<ServerLink>> futures = new ArrayList<>();
for (ServerUrl url : urls) {
futures.add(url.getServerLink(plugin, player));
}
return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream()
.map(CompletableFuture::join).toList());
}
private Optional<ServerLink.Type> getBuiltInLabel() {
final String label = label().replaceAll(" ", "_").toUpperCase(Locale.ENGLISH);
return Arrays.stream(ServerLink.Type.values()).filter(type -> type.name().equals(label)).findFirst();
}
// Validate a ServerUrl
void validate() throws IllegalStateException {
if (label().isEmpty()) {
throw new IllegalStateException("Server URL label cannot be empty");
}
if (url().isEmpty()) {
throw new IllegalStateException("Server URL cannot be empty");
}
if (groups().isEmpty()) {
throw new IllegalStateException("Server URL must have at least one group, or '*' to show on all groups");
}
try {
//noinspection ResultOfMethodCallIgnored
URI.create(url());
} catch (IllegalArgumentException e) {
throw new IllegalStateException("Server URL is not a valid URI");
}
}
}

View File

@ -27,6 +27,7 @@ import lombok.NoArgsConstructor;
import net.william278.velocitab.Velocitab; import net.william278.velocitab.Velocitab;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map; import java.util.Map;
@ -96,6 +97,18 @@ public class Settings implements ConfigValidator {
@Comment("Whether to enable the Plugin Message API (allows backend plugins to perform certain operations)") @Comment("Whether to enable the Plugin Message API (allows backend plugins to perform certain operations)")
private boolean enablePluginMessageApi = true; private boolean enablePluginMessageApi = true;
@Comment({"A list of links that will be sent to display on player pause menus (Minecraft 1.21+ clients only).",
"• Labels can be fully custom or built-in (one of 'bug_report', 'community_guidelines', 'support', 'status',",
" 'feedback', 'community', 'website', 'forums', 'news', or 'announcements').",
"• If you supply a url with a 'bug_report' label, it will be shown if the player is disconnected.",
"• Specify a set of server groups each URL should be sent on. Use '*' to show a URL to all groups."})
private List<ServerUrl> serverLinks = List.of(
new ServerUrl(
"&#00fb9a&About Velocitab",
"https://william278.net/project/velocitab"
)
);
/** /**
* Get display name for the server * Get display name for the server
* *
@ -107,10 +120,19 @@ public class Settings implements ConfigValidator {
return serverDisplayNames.getOrDefault(serverName, serverName); return serverDisplayNames.getOrDefault(serverName, serverName);
} }
@NotNull
public List<ServerUrl> getUrlsForGroup(@NotNull Group group) {
return serverLinks.stream()
.filter(link -> link.groups().contains("*") || link.groups().contains(group.name()))
.toList();
}
@Override @Override
public void validateConfig(@NotNull Velocitab plugin) { public void validateConfig(@NotNull Velocitab plugin) {
if (papiCacheTime < 0) { if (papiCacheTime < 0) {
throw new IllegalStateException("PAPI cache time must be greater than or equal to 0"); throw new IllegalStateException("PAPI cache time must be greater than or equal to 0");
} }
serverLinks.forEach(ServerUrl::validate);
} }
} }

View File

@ -20,6 +20,7 @@
package net.william278.velocitab.tab; package net.william278.velocitab.tab;
import com.google.common.collect.Maps; import com.google.common.collect.Maps;
import com.velocitypowered.api.network.ProtocolVersion;
import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.player.TabList; import com.velocitypowered.api.proxy.player.TabList;
@ -33,6 +34,7 @@ import net.william278.velocitab.Velocitab;
import net.william278.velocitab.api.PlayerAddedToTabEvent; import net.william278.velocitab.api.PlayerAddedToTabEvent;
import net.william278.velocitab.config.Group; import net.william278.velocitab.config.Group;
import net.william278.velocitab.config.Placeholder; import net.william278.velocitab.config.Placeholder;
import net.william278.velocitab.config.ServerUrl;
import net.william278.velocitab.player.Role; import net.william278.velocitab.player.Role;
import net.william278.velocitab.player.TabPlayer; import net.william278.velocitab.player.TabPlayer;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
@ -142,18 +144,19 @@ public class PlayerTabList {
protected void joinPlayer(@NotNull Player joined, @NotNull Group group) { protected void joinPlayer(@NotNull Player joined, @NotNull Group group) {
// Add the player to the tracking list if they are not already listed // Add the player to the tracking list if they are not already listed
final TabPlayer tabPlayer = getTabPlayer(joined).orElseGet(() -> createTabPlayer(joined, group)); final TabPlayer tabPlayer = getTabPlayer(joined).orElseGet(() -> createTabPlayer(joined, group));
final boolean isVanished = plugin.getVanishManager().isVanished(joined.getUsername());
tabPlayer.setGroup(group); tabPlayer.setGroup(group);
players.putIfAbsent(joined.getUniqueId(), tabPlayer); players.putIfAbsent(joined.getUniqueId(), tabPlayer);
// Store the player's last server, so it's possible to have the last server on disconnect
final String serverName = getServerName(joined); final String serverName = getServerName(joined);
//store last server, so it's possible to have the last server on disconnect
tabPlayer.setLastServer(serverName); tabPlayer.setLastServer(serverName);
final boolean isVanished = plugin.getVanishManager().isVanished(joined.getUsername()); // Send server URLs (1.21 clients)
sendPlayerServerLinks(tabPlayer);
// Determine display name, update TAB for player
tabPlayer.getDisplayName(plugin).thenAccept(d -> { tabPlayer.getDisplayName(plugin).thenAccept(d -> {
joined.getTabList().getEntry(joined.getUniqueId()) joined.getTabList().getEntry(joined.getUniqueId())
.ifPresentOrElse(e -> e.setDisplayName(d), .ifPresentOrElse(e -> e.setDisplayName(d),
() -> joined.getTabList().addEntry(createEntry(tabPlayer, joined.getTabList(), d))); () -> joined.getTabList().addEntry(createEntry(tabPlayer, joined.getTabList(), d)));
@ -169,7 +172,6 @@ public class PlayerTabList {
final Set<String> serversInGroup = group.registeredServers(plugin).stream() final Set<String> serversInGroup = group.registeredServers(plugin).stream()
.map(server -> server.getServerInfo().getName()) .map(server -> server.getServerInfo().getName())
.collect(HashSet::new, HashSet::add, HashSet::addAll); .collect(HashSet::new, HashSet::add, HashSet::addAll);
serversInGroup.remove(serverName); serversInGroup.remove(serverName);
// Update lists // Update lists
@ -342,6 +344,14 @@ public class PlayerTabList {
}); });
} }
public void sendPlayerServerLinks(@NotNull TabPlayer player) {
if (player.getPlayer().getProtocolVersion().lessThan(ProtocolVersion.MINECRAFT_1_21)) {
return;
}
final List<ServerUrl> urls = plugin.getSettings().getUrlsForGroup(player.getGroup());
ServerUrl.resolve(plugin, player, urls).thenAccept(player.getPlayer()::setServerLinks);
}
public void updatePlayerDisplayName(@NotNull TabPlayer tabPlayer) { public void updatePlayerDisplayName(@NotNull TabPlayer tabPlayer) {
final Component lastDisplayName = tabPlayer.getLastDisplayName(); final Component lastDisplayName = tabPlayer.getLastDisplayName();
tabPlayer.getDisplayName(plugin).thenAccept(displayName -> { tabPlayer.getDisplayName(plugin).thenAccept(displayName -> {
@ -475,10 +485,10 @@ public class PlayerTabList {
*/ */
public void reloadUpdate() { public void reloadUpdate() {
plugin.getTabGroups().getGroups().forEach(this::updatePeriodically); plugin.getTabGroups().getGroups().forEach(this::updatePeriodically);
if (players.isEmpty()) { if (players.isEmpty()) {
return; return;
} }
// If the update time is set to 0 do not schedule the updater // If the update time is set to 0 do not schedule the updater
players.values().forEach(player -> { players.values().forEach(player -> {
final Optional<ServerConnection> server = player.getPlayer().getCurrentServer(); final Optional<ServerConnection> server = player.getPlayer().getCurrentServer();
@ -488,6 +498,7 @@ public class PlayerTabList {
final String serverName = server.get().getServerInfo().getName(); final String serverName = server.get().getServerInfo().getName();
final Group group = getGroup(serverName); final Group group = getGroup(serverName);
player.setGroup(group); player.setGroup(group);
this.sendPlayerServerLinks(player);
this.updatePlayer(player, true); this.updatePlayer(player, true);
player.sendHeaderAndFooter(this); player.sendHeaderAndFooter(this);
}); });

View File

@ -82,11 +82,12 @@ public class TabListListener {
@Subscribe @Subscribe
public void onPlayerJoin(@NotNull ServerPostConnectEvent event) { public void onPlayerJoin(@NotNull ServerPostConnectEvent event) {
final Player joined = event.getPlayer(); final Player joined = event.getPlayer();
final String serverName = joined.getCurrentServer() final String serverName = joined.getCurrentServer()
.map(ServerConnection::getServerInfo) .map(ServerConnection::getServerInfo)
.map(ServerInfo::getName) .map(ServerInfo::getName)
.orElse(""); .orElse("");
// Get the group the player should now be in
final Group group = tabList.getGroup(serverName); final Group group = tabList.getGroup(serverName);
plugin.getScoreboardManager().ifPresent(manager -> manager.resetCache(joined, group)); plugin.getScoreboardManager().ifPresent(manager -> manager.resetCache(joined, group));
final boolean isDefault = group.registeredServers(plugin).stream() final boolean isDefault = group.registeredServers(plugin).stream()