diff --git a/src/generated/resources/assets/ltextras/lang/en_ud.json b/src/generated/resources/assets/ltextras/lang/en_ud.json index d8cb031c..f57b936b 100644 --- a/src/generated/resources/assets/ltextras/lang/en_ud.json +++ b/src/generated/resources/assets/ltextras/lang/en_ud.json @@ -114,6 +114,7 @@ "block.ltextras.water_barrier": "ɹǝıɹɹɐᗺ ɹǝʇɐM", "block.ltextras.white_glow_sticks": "sʞɔıʇS ʍoן⅁ ǝʇıɥM", "block.ltextras.yellow_glow_sticks": "sʞɔıʇS ʍoן⅁ ʍoןןǝʎ", + "chat.ltextras.message_translated": "˙ʇɟǝן ǝɥʇ oʇ ɹɐq ǝʇıɥʍ ǝɥʇ ɹǝʌo buıɹǝʌoɥ ʎq ǝbɐssǝɯ ןɐuıbıɹo ǝɥʇ ǝǝs uɐɔ noʎ ˙ǝʇɐɹnɔɔɐ ǝq ʇou ʎɐɯ ʇı puɐ 'pǝʇɐןsuɐɹʇ-ǝuıɥɔɐɯ uǝǝq sɐɥ ǝbɐssǝɯ ʇɐɥɔ sıɥ⟘", "commands.tpa.general_error": "ʇɹodǝןǝʇ oʇ ǝןqɐu∩", "commands.tpa.help.back": "buıʇɹodǝןǝʇ ǝɹoɟǝq ǝɹǝʍ noʎ ǝɹǝɥʍ oʇ ʞɔɐq ʇɹodǝןǝ⟘ - ʞɔɐq/", "commands.tpa.help.tpa": "ɹǝʎɐןd ɐ oʇ ʇɹodǝןǝʇ oʇ ʇsǝnbǝᴚ - >ɹǝʎɐןd< ɐdʇ/", diff --git a/src/generated/resources/assets/ltextras/lang/en_us.json b/src/generated/resources/assets/ltextras/lang/en_us.json index be1be784..52f09887 100644 --- a/src/generated/resources/assets/ltextras/lang/en_us.json +++ b/src/generated/resources/assets/ltextras/lang/en_us.json @@ -114,6 +114,7 @@ "block.ltextras.water_barrier": "Water Barrier", "block.ltextras.white_glow_sticks": "White Glow Sticks", "block.ltextras.yellow_glow_sticks": "Yellow Glow Sticks", + "chat.ltextras.message_translated": "This chat message has been machine-translated, and it may not be accurate. You can see the original message by hovering over the white bar to the left.", "commands.tpa.general_error": "Unable to teleport", "commands.tpa.help.back": "/back - Teleport back to where you were before teleporting", "commands.tpa.help.tpa": "/tpa - Request to teleport to a player", diff --git a/src/main/java/com/lovetropics/extras/ExtraLangKeys.java b/src/main/java/com/lovetropics/extras/ExtraLangKeys.java index f9308163..ffbafcbd 100644 --- a/src/main/java/com/lovetropics/extras/ExtraLangKeys.java +++ b/src/main/java/com/lovetropics/extras/ExtraLangKeys.java @@ -15,6 +15,7 @@ public enum ExtraLangKeys { CLUB_INVITE_1_BOTTOM("invite", "club_1.bottom", "Did you know disguises are fireproof?"), CLUB_INVITE_2_TOP("invite", "club_2.top", "See you there.\nWear a disguise.\nGrab a drink."), CLUB_INVITE_2_BOTTOM("invite", "club_2.bottom", "I hear the Limeade's good."), + MESSAGE_TRANSLATED("chat", "message_translated", "This chat message has been machine-translated, and it may not be accurate. You can see the original message by hovering over the white bar to the left.") ; private final String key; diff --git a/src/main/java/com/lovetropics/extras/ExtrasConfig.java b/src/main/java/com/lovetropics/extras/ExtrasConfig.java index adf5926f..d6934cac 100644 --- a/src/main/java/com/lovetropics/extras/ExtrasConfig.java +++ b/src/main/java/com/lovetropics/extras/ExtrasConfig.java @@ -17,6 +17,7 @@ public class ExtrasConfig { public static final class CategoryTechStack { public final ConfigValue authKey; public final ConfigValue scheduleUrl; + public final ConfigValue translationUrl; private CategoryTechStack() { COMMON_BUILDER.comment("Connection to the tech stack").push("techStack"); @@ -29,6 +30,10 @@ private CategoryTechStack() { .comment("API URL to get stream schedule from") .define("schedule", "http://localhost/schedule"); + translationUrl = COMMON_BUILDER + .comment("API URL to request translations from") + .define("translationUrl", ""); + COMMON_BUILDER.pop(); } } diff --git a/src/main/java/com/lovetropics/extras/mixin/translation/FutureChainMixin.java b/src/main/java/com/lovetropics/extras/mixin/translation/FutureChainMixin.java new file mode 100644 index 00000000..5d37a15d --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/translation/FutureChainMixin.java @@ -0,0 +1,36 @@ +package com.lovetropics.extras.mixin.translation; + +import net.minecraft.util.FutureChain; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Mutable; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.concurrent.Executor; + +@Mixin(FutureChain.class) +public class FutureChainMixin { + @Shadow + @Final + @Mutable + private Executor checkedExecutor; + @Shadow + private volatile boolean closed; + + @Inject(method = "", at = @At("TAIL")) + private void init(final Executor executor, final CallbackInfo ci) { + // Fix for a race condition that we're exposing when a player disconnects + checkedExecutor = task -> { + if (!closed) { + executor.execute(() -> { + if (!closed) { + task.run(); + } + }); + } + }; + } +} diff --git a/src/main/java/com/lovetropics/extras/mixin/translation/MessageArgumentMixin.java b/src/main/java/com/lovetropics/extras/mixin/translation/MessageArgumentMixin.java new file mode 100644 index 00000000..30b891cd --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/translation/MessageArgumentMixin.java @@ -0,0 +1,55 @@ +package com.lovetropics.extras.mixin.translation; + +import com.lovetropics.extras.translation.TranslatableChatMessage; +import com.lovetropics.extras.translation.TranslationBundle; +import com.lovetropics.extras.translation.TranslationService; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.arguments.MessageArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.PlayerChatMessage; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.FilteredText; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Overwrite; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +@Mixin(MessageArgument.class) +public abstract class MessageArgumentMixin { + @Shadow + private static CompletableFuture filterPlainText(final CommandSourceStack pSource, final PlayerChatMessage pMessage) { + return null; + } + + /** + * @author Gegy + * @reason also does not need to be, please FIXME! + */ + @Overwrite + private static void resolveSignedMessage(final Consumer callback, final CommandSourceStack source, final PlayerChatMessage message) { + final MinecraftServer server = source.getServer(); + final CompletableFuture filteredText = filterPlainText(source, message); + final CompletableFuture decoratedText = server.getChatDecorator().decorate(source.getPlayer(), message.decoratedContent()); + final CompletableFuture translations = ltextras$translate(source, message); + source.getChatMessageChainer().append(executor -> + CompletableFuture.allOf(filteredText, decoratedText, translations).thenAcceptAsync(unused -> { + final PlayerChatMessage decoratedMessage = message.withUnsignedContent(decoratedText.join()).filter(filteredText.join().mask()); + ((TranslatableChatMessage) (Object) decoratedMessage).ltextras$addTranslations(translations.join()); + callback.accept(decoratedMessage); + }, executor) + ); + } + + @Unique + private static CompletableFuture ltextras$translate(final CommandSourceStack source, final PlayerChatMessage message) { + final ServerPlayer player = source.getPlayer(); + if (player == null) { + return CompletableFuture.completedFuture(TranslationBundle.EMPTY); + } + return TranslationService.INSTANCE.translate(player.getLanguage(), message.signedContent()); + } +} diff --git a/src/main/java/com/lovetropics/extras/mixin/translation/PlayerChatMessageMixin.java b/src/main/java/com/lovetropics/extras/mixin/translation/PlayerChatMessageMixin.java new file mode 100644 index 00000000..cc592652 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/translation/PlayerChatMessageMixin.java @@ -0,0 +1,42 @@ +package com.lovetropics.extras.mixin.translation; + +import com.lovetropics.extras.translation.TranslatableChatMessage; +import com.lovetropics.extras.translation.TranslationBundle; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.PlayerChatMessage; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; + +import java.util.Map; + +@Mixin(PlayerChatMessage.class) +public abstract class PlayerChatMessageMixin implements TranslatableChatMessage { + @Shadow + public abstract String signedContent(); + + @Unique + private final Map ltextras$translations = new Object2ObjectOpenHashMap<>(); + + @Override + public void ltextras$addTranslations(final TranslationBundle bundle) { + ltextras$translations.putAll(bundle.stringsByLanguage()); + } + + @Override + public PlayerChatMessage ltextras$translate(final String language) { + final PlayerChatMessage self = (PlayerChatMessage) (Object) this; + final String translation = ltextras$translations.get(language); + if (translation != null && !translation.equals(signedContent())) { + return self.withUnsignedContent(Component.literal(translation)); + } + return self; + } + + @Override + public boolean ltextras$hasTranslationFor(final String language) { + final String translation = ltextras$translations.get(language); + return translation != null && !translation.equals(signedContent()); + } +} diff --git a/src/main/java/com/lovetropics/extras/mixin/translation/ServerGamePacketListenerImplMixin.java b/src/main/java/com/lovetropics/extras/mixin/translation/ServerGamePacketListenerImplMixin.java new file mode 100644 index 00000000..ee8d00ac --- /dev/null +++ b/src/main/java/com/lovetropics/extras/mixin/translation/ServerGamePacketListenerImplMixin.java @@ -0,0 +1,112 @@ +package com.lovetropics.extras.mixin.translation; + +import com.lovetropics.extras.ExtraLangKeys; +import com.lovetropics.extras.translation.TranslatableChatMessage; +import com.lovetropics.extras.translation.TranslationBundle; +import com.lovetropics.extras.translation.TranslationService; +import net.minecraft.network.chat.*; +import net.minecraft.network.protocol.game.ServerboundChatPacket; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.server.network.FilteredText; +import net.minecraft.server.network.ServerGamePacketListenerImpl; +import net.minecraft.util.FutureChain; +import net.minecraftforge.common.ForgeHooks; +import org.spongepowered.asm.mixin.*; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.ModifyVariable; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +@Mixin(ServerGamePacketListenerImpl.class) +public abstract class ServerGamePacketListenerImplMixin { + @Unique + private static final Component TRANSLATED_MARKER = Component.literal(" \uE041").withStyle(style -> style.withHoverEvent( + new HoverEvent(HoverEvent.Action.SHOW_TEXT, ExtraLangKeys.MESSAGE_TRANSLATED.get()) + )); + + @Shadow + @Final + private MinecraftServer server; + @Shadow + public ServerPlayer player; + @Shadow + @Final + private FutureChain chatMessageChain; + + @Shadow + private static boolean isChatMessageIllegal(final String pMessage) { + return false; + } + + @Shadow + public abstract void disconnect(final Component pTextComponent); + + @Shadow + protected abstract Optional tryHandleChat(final String pMessage, final Instant pTimestamp, final LastSeenMessages.Update pUpdate); + + @Shadow + protected abstract PlayerChatMessage getSignedMessage(final ServerboundChatPacket pPacket, final LastSeenMessages pLastSeenMessages) throws SignedMessageChain.DecodeException; + + @Shadow + protected abstract CompletableFuture filterTextPacket(final String pText); + + @Shadow + protected abstract void handleMessageDecodeFailure(final SignedMessageChain.DecodeException pException); + + @Shadow + protected abstract void broadcastChatMessage(final PlayerChatMessage pMessage); + + /** + * @author Gegy + * @reason No good reason, please FIXME! + */ + @Overwrite + public void handleChat(final ServerboundChatPacket packet) { + if (isChatMessageIllegal(packet.message())) { + disconnect(Component.translatable("multiplayer.disconnect.illegal_characters")); + return; + } + final Optional lastSeen = tryHandleChat(packet.message(), packet.timeStamp(), packet.lastSeenMessages()); + if (lastSeen.isPresent()) { + server.submit(() -> { + final PlayerChatMessage message; + try { + message = getSignedMessage(packet, lastSeen.get()); + } catch (final SignedMessageChain.DecodeException e) { + handleMessageDecodeFailure(e); + return; + } + + final CompletableFuture filteredText = filterTextPacket(message.signedContent()); + final CompletableFuture decoratedText = ForgeHooks.getServerChatSubmittedDecorator().decorate(player, message.decoratedContent()); + // Also request translations at this point, but make sure to not change the order that we distribute chat messages + final CompletableFuture translations = TranslationService.INSTANCE.translate(player.getLanguage(), message.signedContent()); + chatMessageChain.append(executor -> CompletableFuture.allOf(filteredText, decoratedText, translations).thenAcceptAsync(unused -> { + final Component decoratedContent = decoratedText.join(); + if (decoratedContent == null) { + return; + } + final PlayerChatMessage decoratedMessage = message.withUnsignedContent(decoratedContent).filter(filteredText.join().mask()); + ((TranslatableChatMessage) (Object) decoratedMessage).ltextras$addTranslations(translations.join()); + broadcastChatMessage(decoratedMessage); + }, executor)); + }); + } + } + + @ModifyVariable(method = "sendPlayerChatMessage", at = @At("HEAD"), argsOnly = true) + private PlayerChatMessage modifyChatMessage(final PlayerChatMessage message) { + return ((TranslatableChatMessage) (Object) message).ltextras$translate(player.getLanguage()); + } + + @ModifyVariable(method = "sendPlayerChatMessage", at = @At("HEAD"), argsOnly = true) + private ChatType.Bound modifyChatMessageType(final ChatType.Bound chatType, final PlayerChatMessage message) { + if (((TranslatableChatMessage) (Object) message).ltextras$hasTranslationFor(player.getLanguage())) { + return new ChatType.Bound(chatType.chatType(), chatType.name().copy().append(TRANSLATED_MARKER), chatType.targetName()); + } + return chatType; + } +} diff --git a/src/main/java/com/lovetropics/extras/translation/TranslatableChatMessage.java b/src/main/java/com/lovetropics/extras/translation/TranslatableChatMessage.java new file mode 100644 index 00000000..31437599 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/translation/TranslatableChatMessage.java @@ -0,0 +1,11 @@ +package com.lovetropics.extras.translation; + +import net.minecraft.network.chat.PlayerChatMessage; + +public interface TranslatableChatMessage { + void ltextras$addTranslations(TranslationBundle bundle); + + PlayerChatMessage ltextras$translate(String language); + + boolean ltextras$hasTranslationFor(String language); +} diff --git a/src/main/java/com/lovetropics/extras/translation/TranslatableLanguage.java b/src/main/java/com/lovetropics/extras/translation/TranslatableLanguage.java new file mode 100644 index 00000000..5200905c --- /dev/null +++ b/src/main/java/com/lovetropics/extras/translation/TranslatableLanguage.java @@ -0,0 +1,23 @@ +package com.lovetropics.extras.translation; + +import net.minecraft.util.StringRepresentable; + +public enum TranslatableLanguage implements StringRepresentable { + ENGLISH("en_us"), + SPANISH("es_es"), + FRENCH("fr_fr"), + ; + + public static final EnumCodec CODEC = StringRepresentable.fromEnum(TranslatableLanguage::values); + + private final String key; + + TranslatableLanguage(final String key) { + this.key = key; + } + + @Override + public String getSerializedName() { + return key; + } +} diff --git a/src/main/java/com/lovetropics/extras/translation/TranslationBundle.java b/src/main/java/com/lovetropics/extras/translation/TranslationBundle.java new file mode 100644 index 00000000..2e1f4b60 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/translation/TranslationBundle.java @@ -0,0 +1,7 @@ +package com.lovetropics.extras.translation; + +import java.util.Map; + +public record TranslationBundle(Map stringsByLanguage) { + public static final TranslationBundle EMPTY = new TranslationBundle(Map.of()); +} diff --git a/src/main/java/com/lovetropics/extras/translation/TranslationMode.java b/src/main/java/com/lovetropics/extras/translation/TranslationMode.java new file mode 100644 index 00000000..fce5e81a --- /dev/null +++ b/src/main/java/com/lovetropics/extras/translation/TranslationMode.java @@ -0,0 +1,22 @@ +package com.lovetropics.extras.translation; + +import net.minecraft.util.StringRepresentable; + +public enum TranslationMode implements StringRepresentable { + EN_ES("en/es"), + ES_EN("es/en"), + EN_FR("en/fr"), + FR_EN("fr/en"), + ; + + private final String key; + + TranslationMode(final String key) { + this.key = key; + } + + @Override + public String getSerializedName() { + return key; + } +} diff --git a/src/main/java/com/lovetropics/extras/translation/TranslationService.java b/src/main/java/com/lovetropics/extras/translation/TranslationService.java new file mode 100644 index 00000000..7941fca8 --- /dev/null +++ b/src/main/java/com/lovetropics/extras/translation/TranslationService.java @@ -0,0 +1,94 @@ +package com.lovetropics.extras.translation; + +import com.google.common.collect.ImmutableMap; +import com.lovetropics.extras.ExtrasConfig; +import com.mojang.logging.LogUtils; +import net.minecraft.Util; +import org.apache.logging.log4j.util.Strings; +import org.slf4j.Logger; + +import javax.annotation.Nullable; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Supplier; + +public class TranslationService { + private static final Logger LOGGER = LogUtils.getLogger(); + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .executor(Util.ioPool()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + public static final TranslationService INSTANCE = new TranslationService(ExtrasConfig.TECH_STACK.translationUrl); + + private final Supplier url; + + private TranslationService(final Supplier url) { + this.url = url; + } + + private boolean isDisabled() { + return Strings.isBlank(url.get()); + } + + public CompletableFuture translate(final String language, final String text) { + final TranslatableLanguage languageType = TranslatableLanguage.CODEC.byName(language); + if (languageType == null || isDisabled()) { + return CompletableFuture.completedFuture(new TranslationBundle(Map.of(language, text))); + } + return switch (languageType) { + case ENGLISH -> + translateTo(text, TranslationMode.EN_ES).thenCombine(translateTo(text, TranslationMode.EN_FR), + (spanish, french) -> createBundle(text, spanish, french) + ); + case FRENCH -> + translateTo(text, TranslationMode.FR_EN).thenCompose(english -> translateTo(english, TranslationMode.EN_ES) + .thenApply(spanish -> createBundle(english, spanish, text)) + ); + case SPANISH -> + translateTo(text, TranslationMode.ES_EN).thenCompose(english -> translateTo(english, TranslationMode.EN_FR) + .thenApply(french -> createBundle(english, text, french)) + ); + }; + } + + private static TranslationBundle createBundle(@Nullable final String english, @Nullable final String spanish, @Nullable final String french) { + final ImmutableMap.Builder stringsByLanguage = ImmutableMap.builderWithExpectedSize(3); + if (english != null) { + stringsByLanguage.put(TranslatableLanguage.ENGLISH.getSerializedName(), english); + } + if (spanish != null) { + stringsByLanguage.put(TranslatableLanguage.SPANISH.getSerializedName(), spanish); + } + if (french != null) { + stringsByLanguage.put(TranslatableLanguage.FRENCH.getSerializedName(), french); + } + return new TranslationBundle(stringsByLanguage.build()); + } + + private CompletableFuture translateTo(final String text, final TranslationMode mode) { + final HttpRequest request = HttpRequest.newBuilder(URI.create(url.get() + "/" + mode.getSerializedName())) + .POST(HttpRequest.BodyPublishers.ofString(text)) + .build(); + return HTTP_CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + if (response.statusCode() >= 200 && response.statusCode() < 300) { + return response.body(); + } else { + LOGGER.warn("Received unexpected response {} from translation of '{}' ({}): {}", response.statusCode(), text, mode, response.body()); + return null; + } + }) + .handle((result, throwable) -> { + if (throwable != null) { + LOGGER.warn("Failed to translate text: '{}' ({})", text, mode, throwable); + } + return result; + }); + } +} diff --git a/src/main/resources/ltextras.mixins.json b/src/main/resources/ltextras.mixins.json index 94541b59..7c211976 100644 --- a/src/main/resources/ltextras.mixins.json +++ b/src/main/resources/ltextras.mixins.json @@ -10,6 +10,7 @@ "CreatureEntityMixin", "DedicatedServerMixin", "FluidStateMixin", + "ForgeHooksMixin", "ItemFrameMixin", "ItemStackMixin", "LivingEntityMixin", @@ -17,7 +18,6 @@ "ScaffoldingBlockMixin", "SignableCommandMixin", "TeamCommandMixin", - "ForgeHooksMixin", "collectible.ItemStackMixin", "collectible.MerchantResultSlotMixin", "collectible.ServerPlayerGameModeMixin", @@ -27,7 +27,11 @@ "perf.ChunkManagerMixin", "tag.MappedRegistryMixin", "tag.NamespacedWrapperMixin", - "tag.TagNetworkSerializationMixin" + "tag.TagNetworkSerializationMixin", + "translation.FutureChainMixin", + "translation.MessageArgumentMixin", + "translation.PlayerChatMessageMixin", + "translation.ServerGamePacketListenerImplMixin" ], "client": [ "client.LocalPlayerMixin",