diff --git a/api/src/main/java/com/velocitypowered/api/proxy/Player.java b/api/src/main/java/com/velocitypowered/api/proxy/Player.java
index dfe9a2bc7..04e65c849 100644
--- a/api/src/main/java/com/velocitypowered/api/proxy/Player.java
+++ b/api/src/main/java/com/velocitypowered/api/proxy/Player.java
@@ -21,6 +21,7 @@ import com.velocitypowered.api.proxy.player.TabList;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.api.util.ModInfo;
+import com.velocitypowered.api.util.ServerLink;
import java.net.InetSocketAddress;
import java.util.Collection;
import java.util.List;
@@ -461,4 +462,16 @@ public interface Player extends
* @sinceMinecraft 1.20.5
*/
void requestCookie(Key key);
+
+ /**
+ * Send the player a list of custom links to display in their client's pause menu.
+ *
+ *
Note that later packets sent by the backend server may override links sent by the proxy.
+ *
+ * @param links an ordered list of {@link ServerLink}s to send to the player
+ * @throws IllegalArgumentException if the player is from a version lower than 1.21
+ * @since 3.3.0
+ * @sinceMinecraft 1.21
+ */
+ void setServerLinks(@NotNull List links);
}
\ No newline at end of file
diff --git a/api/src/main/java/com/velocitypowered/api/util/ServerLink.java b/api/src/main/java/com/velocitypowered/api/util/ServerLink.java
new file mode 100644
index 000000000..9eb04a980
--- /dev/null
+++ b/api/src/main/java/com/velocitypowered/api/util/ServerLink.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2021-2024 Velocity Contributors
+ *
+ * The Velocity API is licensed under the terms of the MIT License. For more details,
+ * reference the LICENSE file in the api top-level directory.
+ */
+
+package com.velocitypowered.api.util;
+
+import com.google.common.base.Preconditions;
+import java.net.URI;
+import java.util.Optional;
+import net.kyori.adventure.text.Component;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Represents a custom URL servers can show in player pause menus.
+ * Links can be of a built-in type or use a custom component text label.
+ */
+public final class ServerLink {
+
+ private @Nullable Type type;
+ private @Nullable Component label;
+ private final URI url;
+
+ private ServerLink(Component label, String url) {
+ this.label = Preconditions.checkNotNull(label, "label");
+ this.url = URI.create(url);
+ }
+
+ private ServerLink(Type type, String url) {
+ this.type = Preconditions.checkNotNull(type, "type");
+ this.url = URI.create(url);
+ }
+
+ /**
+ * Construct a server link with a custom component label.
+ *
+ * @param label a custom component label to display
+ * @param link the URL to open when clicked
+ */
+ public static ServerLink serverLink(Component label, String link) {
+ return new ServerLink(label, link);
+ }
+
+ /**
+ * Construct a server link with a built-in type.
+ *
+ * @param type the {@link Type built-in type} of link
+ * @param link the URL to open when clicked
+ */
+ public static ServerLink serverLink(Type type, String link) {
+ return new ServerLink(type, link);
+ }
+
+ /**
+ * Get the type of the server link.
+ *
+ * @return the type of the server link
+ */
+ public Optional getBuiltInType() {
+ return Optional.ofNullable(type);
+ }
+
+ /**
+ * Get the custom component label of the server link.
+ *
+ * @return the custom component label of the server link
+ */
+ public Optional getCustomLabel() {
+ return Optional.ofNullable(label);
+ }
+
+ /**
+ * Get the link {@link URI}.
+ *
+ * @return the link {@link URI}
+ */
+ public URI getUrl() {
+ return url;
+ }
+
+ /**
+ * Built-in types of server links.
+ *
+ * @apiNote {@link Type#BUG_REPORT} links are shown on the connection error screen
+ */
+ public enum Type {
+ BUG_REPORT,
+ COMMUNITY_GUIDELINES,
+ SUPPORT,
+ STATUS,
+ FEEDBACK,
+ COMMUNITY,
+ WEBSITE,
+ FORUMS,
+ NEWS,
+ ANNOUNCEMENTS
+ }
+
+}
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java
index de828382e..8522dfce7 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ConnectedPlayer.java
@@ -55,6 +55,7 @@ import com.velocitypowered.api.proxy.player.ResourcePackInfo;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.util.GameProfile;
import com.velocitypowered.api.util.ModInfo;
+import com.velocitypowered.api.util.ServerLink;
import com.velocitypowered.proxy.VelocityServer;
import com.velocitypowered.proxy.adventure.VelocityBossBarImplementation;
import com.velocitypowered.proxy.connection.MinecraftConnection;
@@ -83,6 +84,7 @@ import com.velocitypowered.proxy.protocol.packet.chat.ComponentHolder;
import com.velocitypowered.proxy.protocol.packet.chat.PlayerChatCompletionPacket;
import com.velocitypowered.proxy.protocol.packet.chat.builder.ChatBuilderFactory;
import com.velocitypowered.proxy.protocol.packet.chat.legacy.LegacyChatPacket;
+import com.velocitypowered.proxy.protocol.packet.config.ClientboundServerLinksPacket;
import com.velocitypowered.proxy.protocol.packet.config.StartUpdatePacket;
import com.velocitypowered.proxy.protocol.packet.title.GenericTitlePacket;
import com.velocitypowered.proxy.protocol.util.ByteBufDataOutput;
@@ -1059,6 +1061,22 @@ public class ConnectedPlayer implements MinecraftConnectionAssociation, Player,
}, connection.eventLoop());
}
+ @Override
+ public void setServerLinks(final @NotNull List links) {
+ Preconditions.checkNotNull(links, "links");
+ Preconditions.checkArgument(
+ this.getProtocolVersion().noLessThan(ProtocolVersion.MINECRAFT_1_21),
+ "Player version must be at least 1.21 to be able to set server links");
+
+ if (connection.getState() != StateRegistry.PLAY
+ && connection.getState() != StateRegistry.CONFIG) {
+ throw new IllegalStateException("Can only send server links in CONFIGURATION or PLAY protocol");
+ }
+
+ connection.write(new ClientboundServerLinksPacket(List.copyOf(links).stream()
+ .map(l -> new ClientboundServerLinksPacket.ServerLink(l, getProtocolVersion())).toList()));
+ }
+
@Override
public void addCustomChatCompletions(@NotNull Collection completions) {
Preconditions.checkNotNull(completions, "completions");
diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/config/ClientboundServerLinksPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/config/ClientboundServerLinksPacket.java
index bee080ee8..274bbb8f9 100644
--- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/config/ClientboundServerLinksPacket.java
+++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/config/ClientboundServerLinksPacket.java
@@ -18,6 +18,7 @@
package com.velocitypowered.proxy.protocol.packet.config;
import com.velocitypowered.api.network.ProtocolVersion;
+import com.velocitypowered.api.util.ServerLink;
import com.velocitypowered.proxy.connection.MinecraftSessionHandler;
import com.velocitypowered.proxy.protocol.MinecraftPacket;
import com.velocitypowered.proxy.protocol.ProtocolUtils;
@@ -66,6 +67,13 @@ public class ClientboundServerLinksPacket implements MinecraftPacket {
}
public record ServerLink(int id, ComponentHolder displayName, String url) {
+
+ public ServerLink(com.velocitypowered.api.util.ServerLink link, ProtocolVersion protocolVersion) {
+ this(link.getBuiltInType().map(Enum::ordinal).orElse(-1),
+ link.getCustomLabel().map(c -> new ComponentHolder(protocolVersion, c)).orElse(null),
+ link.getUrl().toString());
+ }
+
private static ServerLink read(ByteBuf buf, ProtocolVersion version) {
if (buf.readBoolean()) {
return new ServerLink(ProtocolUtils.readVarInt(buf), null, ProtocolUtils.readString(buf));