Skip to content

Commit

Permalink
Add live chat translation (pending service)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gegy committed Oct 30, 2023
1 parent c1d329b commit 89bb092
Show file tree
Hide file tree
Showing 14 changed files with 416 additions and 2 deletions.
1 change: 1 addition & 0 deletions src/generated/resources/assets/ltextras/lang/en_ud.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ʇ/",
Expand Down
1 change: 1 addition & 0 deletions src/generated/resources/assets/ltextras/lang/en_us.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <player> - Request to teleport to a player",
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/lovetropics/extras/ExtraLangKeys.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/lovetropics/extras/ExtrasConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public class ExtrasConfig {
public static final class CategoryTechStack {
public final ConfigValue<String> authKey;
public final ConfigValue<String> scheduleUrl;
public final ConfigValue<String> translationUrl;

private CategoryTechStack() {
COMMON_BUILDER.comment("Connection to the tech stack").push("techStack");
Expand All @@ -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();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<init>", 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();
}
});
}
};
}
}
Original file line number Diff line number Diff line change
@@ -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<FilteredText> 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<PlayerChatMessage> callback, final CommandSourceStack source, final PlayerChatMessage message) {
final MinecraftServer server = source.getServer();
final CompletableFuture<FilteredText> filteredText = filterPlainText(source, message);
final CompletableFuture<Component> decoratedText = server.getChatDecorator().decorate(source.getPlayer(), message.decoratedContent());
final CompletableFuture<TranslationBundle> 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<TranslationBundle> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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<LastSeenMessages> 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<FilteredText> 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<LastSeenMessages> 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> filteredText = filterTextPacket(message.signedContent());
final CompletableFuture<Component> 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<TranslationBundle> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<TranslatableLanguage> CODEC = StringRepresentable.fromEnum(TranslatableLanguage::values);

private final String key;

TranslatableLanguage(final String key) {
this.key = key;
}

@Override
public String getSerializedName() {
return key;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.lovetropics.extras.translation;

import java.util.Map;

public record TranslationBundle(Map<String, String> stringsByLanguage) {
public static final TranslationBundle EMPTY = new TranslationBundle(Map.of());
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 89bb092

Please sign in to comment.