feat: add fetchprofile command

This commit is contained in:
Alexander Medvedev
2026-08-07 14:01:03 +02:00
parent 19ccd152f1
commit 7e2505b055
7 changed files with 609 additions and 4 deletions

View File

@@ -237,6 +237,11 @@ impl TextComponent {
TextContent::Custom { key, with, .. } => {
text.push_str(&get_translation_text(key.clone(), locale, with.clone()));
}
TextContent::PlayerSprite { profile, .. } => {
if let Some(name) = profile.0.get_string("name") {
text.push_str(name);
}
}
}
// 4. Recursively append extra components

View File

@@ -139,6 +139,20 @@ impl TextComponentBase {
compound.put_list("with", list);
}
}
TextContent::PlayerSprite {
type_name,
profile,
hat,
} => {
let full_type = if type_name.contains(':') {
type_name.to_string()
} else {
format!("minecraft:{type_name}")
};
compound.put_string("type", full_type);
compound.put_compound("player", profile.0.clone());
compound.put_byte("hat", i8::from(*hat));
}
}
if let Some(ref color) = self.style.color {
@@ -300,6 +314,10 @@ impl TextComponentBase {
} => selector.into_owned(),
TextContent::Keybind { keybind } => keybind.into_owned(),
TextContent::Custom { key, with, .. } => translation_to_pretty(key, Locale::EnUs, with),
TextContent::PlayerSprite { ref profile, .. } => profile
.0
.get_string("name")
.map_or_else(|| "player_sprite".to_string(), ToString::to_string),
};
let style = self.style;
let color = style.color;
@@ -352,6 +370,11 @@ impl TextComponentBase {
TextContent::Custom { key, .. } => {
let _ = write!(text, "%{key}");
}
TextContent::PlayerSprite { profile, .. } => {
if let Some(name) = profile.0.get_string("name") {
text.push_str(name);
}
}
}
for child in &self.extra {
@@ -412,6 +435,11 @@ impl TextComponentBase {
TextContent::Custom { key, with, .. } => {
text.push_str(&get_translation_text(key.clone(), locale, with.clone()));
}
TextContent::PlayerSprite { profile, .. } => {
if let Some(name) = profile.0.get_string("name") {
text.push_str(name);
}
}
}
// 3. Recursively append extra components
@@ -450,6 +478,11 @@ impl TextComponentBase {
} => selector.into_owned(),
TextContent::Keybind { keybind } => keybind.into_owned(),
TextContent::Custom { key, with, .. } => get_translation_text(key, locale, with),
TextContent::PlayerSprite { profile, .. } => profile
.0
.get_string("name")
.map(ToString::to_string)
.unwrap_or_default(),
};
// Recursively append the text of all child components
@@ -800,6 +833,20 @@ impl TextComponent {
}
impl TextComponent {
/// Creates a player sprite component.
#[must_use]
pub fn player_sprite(profile: pumpkin_nbt::NbtCompound, hat: bool) -> Self {
Self(TextComponentBase {
content: Box::new(TextContent::PlayerSprite {
type_name: Cow::Borrowed("minecraft:player_sprite"),
profile: ProfileNbt(profile),
hat,
}),
style: Box::default(),
extra: vec![],
})
}
/// Encodes this component into a byte array using NBT serialization.
///
/// # Returns
@@ -1158,6 +1205,17 @@ impl TextComponent {
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ProfileNbt(pub pumpkin_nbt::NbtCompound);
impl Eq for ProfileNbt {}
impl std::hash::Hash for ProfileNbt {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.to_string().hash(state);
}
}
/// The content type of the text component.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(untagged)]
@@ -1203,6 +1261,13 @@ pub enum TextContent {
/// Substitution parameters for the translation.
with: Vec<TextComponentBase>,
},
/// A player sprite object component.
#[serde(skip)]
PlayerSprite {
type_name: Cow<'static, str>,
profile: ProfileNbt,
hat: bool,
},
}
/// Tests for the text component implementations.

View File

@@ -0,0 +1,387 @@
use crate::command::argument_builder::{ArgumentBuilder, argument, command, literal};
use crate::command::argument_types::core::string::StringArgumentType;
use crate::command::argument_types::entity::EntityArgumentType;
use crate::command::argument_types::uuid::UuidArgumentType;
use crate::command::context::command_context::CommandContext;
use crate::command::context::command_source::CommandSource;
use crate::command::errors::error_types::CommandErrorType;
use crate::command::node::dispatcher::CommandDispatcher;
use crate::command::node::{CommandExecutor, CommandExecutorResult};
use crate::entity::EntityBase;
use crate::net::authentication::{fetch_profile_by_uuid, lookup_profile_by_name};
use crate::net::{GameProfile, offline_uuid};
use crate::server::Server;
use pumpkin_data::translation;
use pumpkin_nbt::compound::NbtCompound;
use pumpkin_nbt::tag::NbtTag;
use pumpkin_util::PermissionLvl;
use pumpkin_util::permission::{Permission, PermissionDefault, PermissionRegistry};
use pumpkin_util::text::click::ClickEvent;
use pumpkin_util::text::{TextComponent, color::NamedColor};
use std::borrow::Cow;
use std::sync::Arc;
use uuid::Uuid;
const DESCRIPTION: &str = "Fetches a player's profile.";
const PERMISSION: &str = "minecraft:command.fetchprofile";
const ARG_NAME: &str = "name";
const ARG_ID: &str = "id";
const ARG_ENTITY: &str = "entity";
pub const NO_PROFILE_ERROR_TYPE: CommandErrorType<1> = CommandErrorType::new(
translation::java::COMMANDS_FETCHPROFILE_NO_PROFILE,
translation::java::COMMANDS_FETCHPROFILE_NO_PROFILE,
);
const fn uuid_to_int_array(uuid: &Uuid) -> [i32; 4] {
let (most, least) = uuid.as_u64_pair();
[
(most >> 32) as i32,
most as i32,
(least >> 32) as i32,
least as i32,
]
}
fn game_profile_to_nbt(profile: &GameProfile) -> NbtCompound {
let mut compound = NbtCompound::new();
let int_array = uuid_to_int_array(&profile.id);
compound.put("id", NbtTag::IntArray(int_array.to_vec()));
if !profile.name.is_empty() {
compound.put_string("name", profile.name.clone());
}
let properties = profile.properties.load();
if !properties.is_empty() {
let mut prop_list = Vec::new();
for prop in properties.iter() {
let mut prop_compound = NbtCompound::new();
prop_compound.put_string("name", prop.name.to_string());
prop_compound.put_string("value", prop.value.to_string());
if let Some(ref sig) = prop.signature {
prop_compound.put_string("signature", sig.to_string());
}
prop_list.push(NbtTag::Compound(prop_compound));
}
compound.put_list("properties", prop_list);
}
compound
}
fn format_clickable_list(items: Vec<TextComponent>) -> TextComponent {
let mut root = TextComponent::empty();
for (i, item) in items.into_iter().enumerate() {
if i > 0 {
root = root.add_child(TextComponent::text(" "));
}
let styled = item.color_named(NamedColor::Green);
let wrapped = styled.wrap_in_square_brackets();
root = root.add_child(wrapped);
}
root
}
async fn report_resolved_profile(
source: &CommandSource,
profile: &GameProfile,
message_id: &'static str,
argument: TextComponent,
) {
let encoded_profile_compound = game_profile_to_nbt(profile);
let encoded_profile_as_string = encoded_profile_compound.to_string();
let head_component = TextComponent::player_sprite(encoded_profile_compound, true);
let encoded_component_as_string = head_component
.0
.clone()
.to_translated()
.to_nbt_compound()
.to_string();
let clickable = format_clickable_list(vec![
TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_COPY_COMPONENT,
translation::java::COMMANDS_FETCHPROFILE_COPY_COMPONENT,
[],
)
.click_event(ClickEvent::CopyToClipboard {
value: Cow::from(encoded_profile_as_string.clone()),
}),
TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_GIVE_ITEM,
translation::java::COMMANDS_FETCHPROFILE_GIVE_ITEM,
[],
)
.click_event(ClickEvent::RunCommand {
command: Cow::from(format!(
"give @s minecraft:player_head[profile={encoded_profile_as_string}]"
)),
}),
TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_SUMMON_MANNEQUIN,
translation::java::COMMANDS_FETCHPROFILE_SUMMON_MANNEQUIN,
[],
)
.click_event(ClickEvent::RunCommand {
command: Cow::from(format!(
"summon minecraft:mannequin ~ ~ ~ {{profile:{encoded_profile_as_string}}}"
)),
}),
TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_COPY_TEXT,
translation::java::COMMANDS_FETCHPROFILE_COPY_TEXT,
[head_component.color_named(NamedColor::White)],
)
.click_event(ClickEvent::CopyToClipboard {
value: Cow::from(encoded_component_as_string),
}),
]);
let msg = TextComponent::translate_cross(message_id, message_id, [argument, clickable]);
source.send_feedback(msg, false).await;
}
async fn fetch_profile_by_name_helper(server: &Server, name: &str) -> Option<GameProfile> {
if let Some(player) = server.get_player_by_name(name) {
return Some(player.gameprofile.clone());
}
let cached_entry = server.data.user_cache.write().await.get_by_name(name);
let auth_config = server
.advanced_config
.networking
.java
.authentication
.clone();
let name_string = name.to_string();
let mojang_res =
tokio::task::spawn_blocking(move || lookup_profile_by_name(&name_string, &auth_config))
.await
.ok()
.and_then(Result::ok)
.flatten();
if let Some((uuid, resolved_name)) = mojang_res {
server
.data
.user_cache
.write()
.await
.upsert(uuid, resolved_name.clone());
let auth_config_clone = server
.advanced_config
.networking
.java
.authentication
.clone();
let full_profile =
tokio::task::spawn_blocking(move || fetch_profile_by_uuid(uuid, &auth_config_clone))
.await
.ok()
.and_then(Result::ok)
.flatten();
return Some(full_profile.unwrap_or_else(|| GameProfile {
id: uuid,
name: resolved_name,
properties: arc_swap::ArcSwap::new(Arc::new(vec![])),
profile_actions: None,
}));
}
if let Some(entry) = cached_entry {
return Some(GameProfile {
id: entry.uuid,
name: entry.name,
properties: arc_swap::ArcSwap::new(Arc::new(vec![])),
profile_actions: None,
});
}
if !server.advanced_config.networking.java.online_mode
&& let Ok(uuid) = offline_uuid(name)
{
let profile = GameProfile {
id: uuid,
name: name.to_string(),
properties: arc_swap::ArcSwap::new(Arc::new(vec![])),
profile_actions: None,
};
server
.data
.user_cache
.write()
.await
.upsert(uuid, name.to_string());
return Some(profile);
}
None
}
async fn fetch_profile_by_id_helper(server: &Server, id: Uuid) -> Option<GameProfile> {
if let Some(player) = server.get_player_by_uuid(id) {
return Some(player.gameprofile.clone());
}
let auth_config = server
.advanced_config
.networking
.java
.authentication
.clone();
let mojang_res = tokio::task::spawn_blocking(move || fetch_profile_by_uuid(id, &auth_config))
.await
.ok()
.and_then(Result::ok)
.flatten();
if let Some(profile) = mojang_res {
server
.data
.user_cache
.write()
.await
.upsert(profile.id, profile.name.clone());
return Some(profile);
}
let cached_entry = server.data.user_cache.write().await.get_by_uuid(id);
if let Some(entry) = cached_entry {
return Some(GameProfile {
id: entry.uuid,
name: entry.name,
properties: arc_swap::ArcSwap::new(Arc::new(vec![])),
profile_actions: None,
});
}
None
}
struct ResolveNameExecutor;
impl CommandExecutor for ResolveNameExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let name = StringArgumentType::get(context, ARG_NAME)?;
let server = context.server().clone();
let source = context.source.clone();
let name_owned = name.to_string();
tokio::spawn(async move {
let name_component = TextComponent::text(name_owned.clone());
let result = fetch_profile_by_name_helper(&server, &name_owned).await;
match result {
Some(profile) => {
report_resolved_profile(
&source,
&profile,
translation::java::COMMANDS_FETCHPROFILE_NAME_SUCCESS,
name_component,
)
.await;
}
None => {
source
.send_error(TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_NAME_FAILURE,
translation::java::COMMANDS_FETCHPROFILE_NAME_FAILURE,
[name_component],
))
.await;
}
}
});
Ok(1)
})
}
}
struct ResolveIdExecutor;
impl CommandExecutor for ResolveIdExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let id = UuidArgumentType::get(context, ARG_ID)?;
let server = context.server().clone();
let source = context.source.clone();
tokio::spawn(async move {
let id_component = TextComponent::text(id.to_string());
let result = fetch_profile_by_id_helper(&server, id).await;
match result {
Some(profile) => {
report_resolved_profile(
&source,
&profile,
translation::java::COMMANDS_FETCHPROFILE_ID_SUCCESS,
id_component,
)
.await;
}
None => {
source
.send_error(TextComponent::translate_cross(
translation::java::COMMANDS_FETCHPROFILE_ID_FAILURE,
translation::java::COMMANDS_FETCHPROFILE_ID_FAILURE,
[id_component],
))
.await;
}
}
});
Ok(1)
})
}
}
struct PrintForEntityExecutor;
impl CommandExecutor for PrintForEntityExecutor {
fn execute<'a>(&'a self, context: &'a CommandContext) -> CommandExecutorResult<'a> {
Box::pin(async move {
let entity = EntityArgumentType::get_entity(context, ARG_ENTITY).await?;
if let Some(player) = entity.get_player() {
report_resolved_profile(
&context.source,
&player.gameprofile,
translation::java::COMMANDS_FETCHPROFILE_ENTITY_SUCCESS,
player.get_display_name().await,
)
.await;
Ok(1)
} else {
Err(NO_PROFILE_ERROR_TYPE.create_without_context(entity.get_display_name().await))
}
})
}
}
pub fn register(dispatcher: &mut CommandDispatcher, registry: &mut PermissionRegistry) {
registry.register_permission_or_panic(Permission::new(
PERMISSION,
DESCRIPTION,
PermissionDefault::Op(PermissionLvl::Two),
));
dispatcher.register(
command("fetchprofile", DESCRIPTION)
.requires(PERMISSION)
.then(literal("name").then(
argument(ARG_NAME, StringArgumentType::GreedyPhrase).executes(ResolveNameExecutor),
))
.then(
literal("id").then(argument(ARG_ID, UuidArgumentType).executes(ResolveIdExecutor)),
)
.then(literal("entity").then(
argument(ARG_ENTITY, EntityArgumentType::Entity).executes(PrintForEntityExecutor),
)),
);
}

View File

@@ -24,6 +24,7 @@ mod effect;
mod enchant;
mod execute;
mod experience;
mod fetchprofile;
mod fill;
mod fillbiome;
mod forceload;
@@ -204,6 +205,7 @@ pub async fn default_dispatcher(
teammsg::register(&mut dispatcher, registry);
clone::register(&mut dispatcher, registry);
attribute::register(&mut dispatcher, registry);
fetchprofile::register(&mut dispatcher, registry);
dispatcher
}

View File

@@ -5,25 +5,69 @@ use pumpkin_util::text::{TextComponent, color::NamedColor};
use pumpkin_util::translation::get_translation_text;
use serde::Deserialize;
use std::borrow::Cow;
use std::sync::{LazyLock, Mutex};
use std::time::{Duration, Instant};
use crate::command::CommandResult;
use crate::command::{CommandExecutor, CommandSender, args::ConsumedArgs, tree::CommandTree};
const NAMES: [&str; 2] = ["pumpkin", "version"];
const NAMES: [&str; 3] = ["pumpkin", "version", "ver"];
const DESCRIPTION: &str = "Display information about Pumpkin.";
const CACHE_DURATION: Duration = Duration::from_hours(24);
struct Executor;
const CARGO_PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const GIT_HASH: &str = env!("GIT_HASH");
const GIT_HASH_FULL: &str = env!("GIT_HASH_FULL");
#[derive(Deserialize)]
#[derive(Deserialize, Clone)]
struct Contributor {
login: String,
}
struct ContributorCache {
fetched_at: Instant,
data: Vec<Contributor>,
}
static CONTRIBUTORS_CACHE: LazyLock<Mutex<Option<ContributorCache>>> =
LazyLock::new(|| Mutex::new(None));
struct DonatorCache {
fetched_at: Instant,
data: TextComponent,
}
static DONATORS_CACHE: LazyLock<Mutex<Option<DonatorCache>>> = LazyLock::new(|| Mutex::new(None));
fn fetch_all_contributors_cached() -> Vec<Contributor> {
if let Ok(guard) = CONTRIBUTORS_CACHE.lock()
&& let Some(cache) = guard.as_ref()
&& cache.fetched_at.elapsed() < CACHE_DURATION
{
return cache.data.clone();
}
let contributors = fetch_all_contributors();
if !contributors.is_empty() {
if let Ok(mut guard) = CONTRIBUTORS_CACHE.lock() {
*guard = Some(ContributorCache {
fetched_at: Instant::now(),
data: contributors.clone(),
});
}
} else if let Ok(guard) = CONTRIBUTORS_CACHE.lock()
&& let Some(cache) = guard.as_ref()
{
return cache.data.clone();
}
contributors
}
fn fetch_all_contributors() -> Vec<Contributor> {
let mut all_contributors = Vec::new();
let mut next_url = Some(
@@ -173,6 +217,25 @@ fn fetch_donators_hover() -> TextComponent {
donators_text.add_child(TextComponent::text("Unable to load donators"))
}
fn fetch_donators_hover_cached() -> TextComponent {
if let Ok(guard) = DONATORS_CACHE.lock()
&& let Some(cache) = guard.as_ref()
&& cache.fetched_at.elapsed() < CACHE_DURATION
{
return cache.data.clone();
}
let donators = fetch_donators_hover();
if let Ok(mut guard) = DONATORS_CACHE.lock() {
*guard = Some(DonatorCache {
fetched_at: Instant::now(),
data: donators.clone(),
});
}
donators
}
#[expect(clippy::too_many_lines)]
impl CommandExecutor for Executor {
fn execute<'a>(
@@ -182,7 +245,7 @@ impl CommandExecutor for Executor {
_args: &'a ConsumedArgs<'a>,
) -> CommandResult<'a> {
Box::pin(async move {
let contributors = tokio::task::spawn_blocking(fetch_all_contributors)
let contributors = tokio::task::spawn_blocking(fetch_all_contributors_cached)
.await
.unwrap_or_default();
let contributor_names = contributors
@@ -284,7 +347,7 @@ impl CommandExecutor for Executor {
msg = msg.add_child(TextComponent::text(" "));
let donators_hover = tokio::task::spawn_blocking(fetch_donators_hover)
let donators_hover = tokio::task::spawn_blocking(fetch_donators_hover_cached)
.await
.unwrap_or_else(|_| TextComponent::text("Unable to load donators"));
msg = msg.add_child(
@@ -329,3 +392,43 @@ impl CommandExecutor for Executor {
pub fn init_command_tree() -> CommandTree {
CommandTree::new(NAMES, DESCRIPTION).execute(Executor)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn cache_duration_is_24_hours() {
assert_eq!(CACHE_DURATION, Duration::from_hours(24));
}
#[test]
fn contributor_cache_updates_and_retrieves() {
let mut guard = CONTRIBUTORS_CACHE.lock().unwrap();
*guard = Some(ContributorCache {
fetched_at: Instant::now(),
data: vec![Contributor {
login: "test_user".to_string(),
}],
});
drop(guard);
let contributors = fetch_all_contributors_cached();
assert_eq!(contributors.len(), 1);
assert_eq!(contributors[0].login, "test_user");
}
#[test]
fn donator_cache_updates_and_retrieves() {
let expected = TextComponent::text("Cached Donator Test");
let mut guard = DONATORS_CACHE.lock().unwrap();
*guard = Some(DonatorCache {
fetched_at: Instant::now(),
data: expected.clone(),
});
drop(guard);
let cached = fetch_donators_hover_cached();
assert_eq!(cached, expected);
}
}

View File

@@ -735,6 +735,22 @@ mod test {
dispatcher.register(tree, "minecraft:test");
}
#[tokio::test]
async fn pumpkin_command_aliases() {
let config = BasicConfiguration::default();
let registry = RwLock::new(PermissionRegistry::new());
let dispatcher = default_dispatcher(&registry, &config)
.await
.fallback_dispatcher;
let pumpkin_tree = dispatcher.get_tree("pumpkin").unwrap();
let version_tree = dispatcher.get_tree("version").unwrap();
let ver_tree = dispatcher.get_tree("ver").unwrap();
assert_eq!(pumpkin_tree.description, version_tree.description);
assert_eq!(pumpkin_tree.description, ver_tree.description);
}
#[test]
fn syntax_renderer_outputs_two_messages_with_context_styling() {
let input = "0123456789abcdefghij";

View File

@@ -214,6 +214,33 @@ pub fn lookup_profile_by_name(
Ok(Some((parsed_uuid, profile.name)))
}
pub fn fetch_profile_by_uuid(
uuid: Uuid,
_auth_config: &AuthenticationConfig,
) -> Result<Option<GameProfile>, AuthError> {
let url = format!(
"https://sessionserver.mojang.com/session/minecraft/profile/{}?unsigned=false",
uuid.simple()
);
let mut response = ureq::get(&url)
.call()
.map_err(|_| AuthError::FailedResponse)?;
match response.status() {
StatusCode::OK => {}
StatusCode::NO_CONTENT | StatusCode::NOT_FOUND => return Ok(None),
other => Err(AuthError::UnknownStatusCode(other))?,
}
let profile: GameProfile = response
.body_mut()
.read_json()
.map_err(|_| AuthError::FailedParse)?;
Ok(Some(profile))
}
#[derive(Error, Debug)]
pub enum AuthError {
#[error("Authentication servers are down")]