diff --git a/api/src/main/java/com/discordsrv/api/component/GameTextBuilder.java b/api/src/main/java/com/discordsrv/api/component/GameTextBuilder.java index 5780f633..563777e0 100644 --- a/api/src/main/java/com/discordsrv/api/component/GameTextBuilder.java +++ b/api/src/main/java/com/discordsrv/api/component/GameTextBuilder.java @@ -23,6 +23,7 @@ package com.discordsrv.api.component; +import com.discordsrv.api.placeholder.provider.SinglePlaceholder; import org.jetbrains.annotations.NotNull; import java.util.function.Function; @@ -38,6 +39,14 @@ public interface GameTextBuilder { @NotNull GameTextBuilder addContext(Object... context); + default GameTextBuilder addPlaceholder(String placeholder, Object replacement) { + return addContext(new SinglePlaceholder(placeholder, replacement)); + } + + default GameTextBuilder addPlaceholder(String placeholder, Supplier replacementSupplier) { + return addContext(new SinglePlaceholder(placeholder, replacementSupplier)); + } + @NotNull default GameTextBuilder addReplacement(String target, Object replacement) { return addReplacement(Pattern.compile(target, Pattern.LITERAL), replacement); diff --git a/api/src/main/java/com/discordsrv/api/discord/DiscordAPI.java b/api/src/main/java/com/discordsrv/api/discord/DiscordAPI.java index 5edb093c..6dc48862 100644 --- a/api/src/main/java/com/discordsrv/api/discord/DiscordAPI.java +++ b/api/src/main/java/com/discordsrv/api/discord/DiscordAPI.java @@ -70,6 +70,23 @@ public interface DiscordAPI { @Nullable DiscordTextChannel getTextChannelById(long id); + /** + * Gets a Discord voice channel by id, the provided entity should be stored for long periods of time. + * @param id the id for the voice channel + * @return the voice channel + */ + @Nullable + DiscordVoiceChannel getVoiceChannelById(long id); + + /** + * Gets a Discord stage channel by id, the provided entity should be stored for long periods of time. + * @param id the id for the voice channel + * @return the voice channel + */ + @Nullable + DiscordStageChannel getStageChannelById(long id); + + /** * Gets a Discord thread channel by id from the cache, the provided entity should not be stored for long periods of time. * @param id the id for the thread channel diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java index 935ac4bc..eb125f85 100644 --- a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java +++ b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordGuildChannel.java @@ -25,6 +25,7 @@ package com.discordsrv.api.discord.entity.channel; import com.discordsrv.api.discord.entity.Snowflake; import com.discordsrv.api.discord.entity.guild.DiscordGuild; +import com.discordsrv.api.placeholder.annotation.Placeholder; import org.jetbrains.annotations.NotNull; public interface DiscordGuildChannel extends Snowflake { @@ -34,6 +35,7 @@ public interface DiscordGuildChannel extends Snowflake { * @return the name of the channel */ @NotNull + @Placeholder("channel_name") String getName(); /** @@ -42,4 +44,12 @@ public interface DiscordGuildChannel extends Snowflake { */ @NotNull DiscordGuild getGuild(); + + /** + * Gets the jump url for this channel + * @return the https url to go to this channel + */ + @NotNull + @Placeholder("channel_jump_url") + String getJumpUrl(); } diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordStageChannel.java b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordStageChannel.java new file mode 100644 index 00000000..d825c012 --- /dev/null +++ b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordStageChannel.java @@ -0,0 +1,12 @@ +package com.discordsrv.api.discord.entity.channel; + +import com.discordsrv.api.discord.entity.JDAEntity; +import net.dv8tion.jda.api.entities.channel.concrete.StageChannel; + +public interface DiscordStageChannel extends DiscordGuildMessageChannel, JDAEntity { + + @Override + default DiscordChannelType getType() { + return DiscordChannelType.STAGE; + } +} diff --git a/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordVoiceChannel.java b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordVoiceChannel.java new file mode 100644 index 00000000..830525bf --- /dev/null +++ b/api/src/main/java/com/discordsrv/api/discord/entity/channel/DiscordVoiceChannel.java @@ -0,0 +1,12 @@ +package com.discordsrv.api.discord.entity.channel; + +import com.discordsrv.api.discord.entity.JDAEntity; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +public interface DiscordVoiceChannel extends DiscordGuildMessageChannel, JDAEntity { + + @Override + default DiscordChannelType getType() { + return DiscordChannelType.VOICE; + } +} diff --git a/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java b/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java index 6251dbb3..b093fe31 100644 --- a/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java +++ b/common/src/main/java/com/discordsrv/common/component/ComponentFactory.java @@ -29,6 +29,9 @@ import dev.vankka.mcdiscordreserializer.discord.DiscordSerializer; import dev.vankka.mcdiscordreserializer.discord.DiscordSerializerOptions; import dev.vankka.mcdiscordreserializer.minecraft.MinecraftSerializer; import dev.vankka.mcdiscordreserializer.minecraft.MinecraftSerializerOptions; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.flattener.ComponentFlattener; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.jetbrains.annotations.NotNull; import java.util.Locale; @@ -48,6 +51,7 @@ public class ComponentFactory implements MinecraftComponentFactory { private final DiscordSRV discordSRV; private final MinecraftSerializer minecraftSerializer; private final DiscordSerializer discordSerializer; + private final PlainTextComponentSerializer plainSerializer; // Not the same as Adventure's TranslationRegistry private final TranslationRegistry translationRegistry = new TranslationRegistry(); @@ -61,20 +65,29 @@ public class ComponentFactory implements MinecraftComponentFactory { this.discordSerializer = new DiscordSerializer(); discordSerializer.setDefaultOptions( DiscordSerializerOptions.defaults() - .withTranslationProvider(translatableComponent -> { - Translation translation = translationRegistry.lookup(Locale.US, translatableComponent.key()); - if (translation == null) { - return null; - } + .withTranslationProvider(this::provideTranslation) + ); + this.plainSerializer = PlainTextComponentSerializer.builder() + .flattener( + ComponentFlattener.basic().toBuilder() + .mapper(TranslatableComponent.class, this::provideTranslation) + .build() + ) + .build(); + } - return translation.translate( - translatableComponent.args() - .stream() - .map(discordSerializer::serialize) - .map(str -> (Object) str) - .toArray(Object[]::new) - ); - }) + private String provideTranslation(TranslatableComponent component) { + Translation translation = translationRegistry.lookup(Locale.US, component.key()); + if (translation == null) { + return null; + } + + return translation.translate( + component.args() + .stream() + .map(discordSerializer::serialize) + .map(str -> (Object) str) + .toArray(Object[]::new) ); } @@ -96,6 +109,10 @@ public class ComponentFactory implements MinecraftComponentFactory { return discordSerializer; } + public PlainTextComponentSerializer plainSerializer() { + return plainSerializer; + } + public TranslationRegistry translationRegistry() { return translationRegistry; } diff --git a/common/src/main/java/com/discordsrv/common/component/renderer/DiscordSRVMinecraftRenderer.java b/common/src/main/java/com/discordsrv/common/component/renderer/DiscordSRVMinecraftRenderer.java index 33652f9f..6d667489 100644 --- a/common/src/main/java/com/discordsrv/common/component/renderer/DiscordSRVMinecraftRenderer.java +++ b/common/src/main/java/com/discordsrv/common/component/renderer/DiscordSRVMinecraftRenderer.java @@ -33,12 +33,16 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.utils.MiscUtil; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; import org.jetbrains.annotations.NotNull; import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class DiscordSRVMinecraftRenderer extends DefaultMinecraftRenderer { + private static final Pattern MESSAGE_URL_PATTERN = Pattern.compile("https://(?:(?:ptb|canary)\\.)?discord\\.com/channels/[0-9]{16,20}/([0-9]{16,20})/[0-9]{16,20}"); private static final ThreadLocal CONTEXT = new ThreadLocal<>(); private final DiscordSRV discordSRV; @@ -69,6 +73,41 @@ public class DiscordSRVMinecraftRenderer extends DefaultMinecraftRenderer { return output; } + @Override + public Component appendLink(@NotNull Component part, String link) { + JDA jda = discordSRV.jda(); + + if (jda != null) { + Matcher matcher = MESSAGE_URL_PATTERN.matcher(link); + if (matcher.matches()) { + String channel = matcher.group(1); + GuildChannel guildChannel = jda.getGuildChannelById(channel); + + Context context = CONTEXT.get(); + String format = context != null ? context.config.map(cfg -> cfg.mentions).get(cfg -> cfg.message) : null; + if (format == null || guildChannel == null) { + return super.appendLink(part, link); + } + + return Component.text() + .clickEvent(ClickEvent.openUrl(link)) + .append( + ComponentUtil.fromAPI( + discordSRV.componentFactory() + .textBuilder(format) + .addContext(guildChannel) + .addPlaceholder("jump_url", link) + .applyPlaceholderService() + .build() + ) + ) + .build(); + } + } + + return super.appendLink(part, link); + } + @Override public @NotNull Component appendChannelMention(@NotNull Component component, @NotNull String id) { Context context = CONTEXT.get(); @@ -88,7 +127,7 @@ public class DiscordSRVMinecraftRenderer extends DefaultMinecraftRenderer { return component.append(ComponentUtil.fromAPI( discordSRV.componentFactory() .textBuilder(guildChannel != null ? format.format : format.unknownFormat) - .addReplacement("%channel_name%", guildChannel != null ? guildChannel.getName() : null) + .addContext(guildChannel) .applyPlaceholderService() .build() )); diff --git a/common/src/main/java/com/discordsrv/common/config/main/channels/DiscordToMinecraftChatConfig.java b/common/src/main/java/com/discordsrv/common/config/main/channels/DiscordToMinecraftChatConfig.java index ef951312..2af2a151 100644 --- a/common/src/main/java/com/discordsrv/common/config/main/channels/DiscordToMinecraftChatConfig.java +++ b/common/src/main/java/com/discordsrv/common/config/main/channels/DiscordToMinecraftChatConfig.java @@ -60,9 +60,11 @@ public class DiscordToMinecraftChatConfig { public static class Mentions { public Format role = new Format("ᛩf2@%role_name%", "ᛩf2@deleted-role"); - public Format channel = new Format("ᛩf2#%channel_name%", "ᛩf2#deleted-channel"); + public Format channel = new Format("[hover:show_text:Click to go to channel][click:open_url:%channel_jump_url%]ᛩf2#%channel_name%", "ᛩf2#deleted-channel"); public Format user = new Format("[hover:show_text:Tag: %user_tag%&r\nRoles: %user_roles_, |text_&7&oNone%]ᛩf2@%user_effective_name|user_name%", "ᛩf2@Unknown user"); + public String message = "[hover:show_text:Click to go to message][click:open_url:%jump_url%]ᛩf2#%channel_name% > ..."; + @ConfigSerializable public static class Format { diff --git a/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java index e9546ddc..37c0d4cd 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/DiscordAPIImpl.java @@ -48,10 +48,7 @@ import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.github.benmanes.caffeine.cache.Expiry; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.*; -import net.dv8tion.jda.api.entities.channel.concrete.NewsChannel; -import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.concrete.*; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.requests.ErrorResponse; @@ -397,6 +394,24 @@ public class DiscordAPIImpl implements DiscordAPI { return new DiscordTextChannelImpl(discordSRV, jda); } + @Override + public @Nullable DiscordVoiceChannel getVoiceChannelById(long id) { + return mapJDAEntity(jda -> jda.getVoiceChannelById(id), this::getVoiceChannel); + } + + public DiscordVoiceChannelImpl getVoiceChannel(VoiceChannel jda) { + return new DiscordVoiceChannelImpl(discordSRV, jda); + } + + @Override + public @Nullable DiscordStageChannel getStageChannelById(long id) { + return mapJDAEntity(jda -> jda.getStageChannelById(id), this::getStageChannel); + } + + public DiscordStageChannelImpl getStageChannel(StageChannel jda) { + return new DiscordStageChannelImpl(discordSRV, jda); + } + @Override public @Nullable DiscordThreadChannel getCachedThreadChannelById(long id) { return mapJDAEntity(jda -> jda.getThreadChannelById(id), this::getThreadChannel); diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java index 7248c824..db37887d 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/AbstractDiscordGuildMessageChannel.java @@ -65,6 +65,11 @@ public abstract class AbstractDiscordGuildMessageChannel. + */ + +package com.discordsrv.common.discord.api.entity.channel; + +import com.discordsrv.api.discord.entity.channel.DiscordStageChannel; +import com.discordsrv.common.DiscordSRV; +import net.dv8tion.jda.api.entities.channel.concrete.StageChannel; + +public class DiscordStageChannelImpl extends AbstractDiscordGuildMessageChannel implements DiscordStageChannel { + + public DiscordStageChannelImpl(DiscordSRV discordSRV, StageChannel stageChannel) { + super(discordSRV, stageChannel); + } + + @Override + public StageChannel asJDA() { + return channel; + } + + @Override + public String toString() { + return "StageChannel:" + getName() + "(" + Long.toUnsignedString(getId()) + ")"; + } +} diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordVoiceChannelImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordVoiceChannelImpl.java new file mode 100644 index 00000000..c435e6c0 --- /dev/null +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/channel/DiscordVoiceChannelImpl.java @@ -0,0 +1,40 @@ +/* + * This file is part of DiscordSRV, licensed under the GPLv3 License + * Copyright (c) 2016-2023 Austin "Scarsz" Shapiro, Henri "Vankka" Schubin and DiscordSRV contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.discordsrv.common.discord.api.entity.channel; + +import com.discordsrv.api.discord.entity.channel.DiscordVoiceChannel; +import com.discordsrv.common.DiscordSRV; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; + +public class DiscordVoiceChannelImpl extends AbstractDiscordGuildMessageChannel implements DiscordVoiceChannel { + + public DiscordVoiceChannelImpl(DiscordSRV discordSRV, VoiceChannel voiceChannel) { + super(discordSRV, voiceChannel); + } + + @Override + public VoiceChannel asJDA() { + return channel; + } + + @Override + public String toString() { + return "VoiceChannel:" + getName() + "(" + Long.toUnsignedString(getId()) + ")"; + } +} diff --git a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java index b9f31a9a..9be13726 100644 --- a/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java +++ b/common/src/main/java/com/discordsrv/common/discord/api/entity/message/ReceivedDiscordMessageImpl.java @@ -336,8 +336,8 @@ public class ReceivedDiscordMessageImpl implements ReceivedDiscordMessage { for (Attachment attachment : attachments) { components.add(ComponentUtil.fromAPI( discordSRV.componentFactory().textBuilder(attachmentFormat) - .addReplacement("%file_name%", attachment.fileName()) - .addReplacement("%file_url%", attachment.url()) + .addPlaceholder("file_name", attachment.fileName()) + .addPlaceholder("file_url", attachment.url()) .build() )); } diff --git a/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java b/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java index ff778852..8b6b154e 100644 --- a/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java +++ b/common/src/main/java/com/discordsrv/common/discord/connection/jda/JDAConnectionManager.java @@ -50,8 +50,7 @@ import com.neovisionaries.ws.client.WebSocketFrame; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.entities.*; -import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.*; import net.dv8tion.jda.api.events.StatusChangeEvent; import net.dv8tion.jda.api.events.session.SessionDisconnectEvent; import net.dv8tion.jda.api.events.session.ShutdownEvent; @@ -236,6 +235,12 @@ public class JDAConnectionManager implements DiscordConnectionManager { converted = api().getDirectMessageChannel((PrivateChannel) o); } else if (o instanceof TextChannel) { converted = api().getTextChannel((TextChannel) o); + } else if (o instanceof ThreadChannel) { + converted = api().getThreadChannel((ThreadChannel) o); + } else if (o instanceof VoiceChannel) { + converted = api().getVoiceChannel((VoiceChannel) o); + } else if (o instanceof StageChannel) { + converted = api().getStageChannel((StageChannel) o); } else if (o instanceof Guild) { converted = api().getGuild((Guild) o); } else if (o instanceof Member) { diff --git a/common/src/main/java/com/discordsrv/common/messageforwarding/discord/DiscordChatMessageModule.java b/common/src/main/java/com/discordsrv/common/messageforwarding/discord/DiscordChatMessageModule.java index 1704202b..30dc99a8 100644 --- a/common/src/main/java/com/discordsrv/common/messageforwarding/discord/DiscordChatMessageModule.java +++ b/common/src/main/java/com/discordsrv/common/messageforwarding/discord/DiscordChatMessageModule.java @@ -129,7 +129,7 @@ public class DiscordChatMessageModule extends AbstractModule { GameTextBuilder componentBuilder = discordSRV.componentFactory() .textBuilder(format) .addContext(discordMessage, author, channel, channelConfig) - .addReplacement("%message%", messageComponent); + .addPlaceholder("message", messageComponent); if (member != null) { componentBuilder.addContext(member); } diff --git a/common/src/main/java/com/discordsrv/common/placeholder/result/ComponentResultStringifier.java b/common/src/main/java/com/discordsrv/common/placeholder/result/ComponentResultStringifier.java index e23ea312..3d3e41ce 100644 --- a/common/src/main/java/com/discordsrv/common/placeholder/result/ComponentResultStringifier.java +++ b/common/src/main/java/com/discordsrv/common/placeholder/result/ComponentResultStringifier.java @@ -25,7 +25,6 @@ import com.discordsrv.api.placeholder.mapper.PlaceholderResultMapper; import com.discordsrv.common.DiscordSRV; import com.discordsrv.common.component.util.ComponentUtil; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.jetbrains.annotations.NotNull; public class ComponentResultStringifier implements PlaceholderResultMapper { @@ -47,7 +46,7 @@ public class ComponentResultStringifier implements PlaceholderResultMapper { switch (mappingState) { case ANSI: // TODO: ansi serializer (?) case PLAIN: - return PlainTextComponentSerializer.plainText().serialize(component); + return discordSRV.componentFactory().plainSerializer().serialize(component); default: case NORMAL: return new FormattedText(discordSRV.componentFactory().discordSerializer().serialize(component)); diff --git a/settings.gradle b/settings.gradle index 9617076e..8c7cc1fb 100644 --- a/settings.gradle +++ b/settings.gradle @@ -130,7 +130,7 @@ dependencyResolutionManagement { library('adventure-serializer-bungee', 'net.kyori', 'adventure-text-serializer-bungeecord').versionRef('adventure-platform') // MCDiscordReserializer & EnhancedLegacyText - library('mcdiscordreserializer', 'dev.vankka', 'mcdiscordreserializer').version('4.3.0') + library('mcdiscordreserializer', 'dev.vankka', 'mcdiscordreserializer').version('4.4.0-SNAPSHOT') library('enhancedlegacytext', 'dev.vankka', 'enhancedlegacytext').version('1.0.0') // JUnit