diff --git a/README.md b/README.md index 5947320..9768e4d 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ # Задачи - [ ] Билды героев Heroes of the storm в дискорде - - [ ] Базовое взаимодействие с дискордом + - [x] Базовое взаимодействие с дискордом + - [x] Получение сообщений от дискорда + - [x] Хранение состояний для связки канал+пользователь + - [x] Обработка "echo", "authors" + - [x] Обработка "build" - [ ] Загрузка талантов героев с icyVeins - - [ ] Толерантность к ошибкам \ No newline at end of file + - [ ] Толерантность к ошибкам diff --git a/checkstyle.xml b/checkstyle.xml index 53692a3..59b01c1 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -220,7 +220,7 @@ - diff --git a/pom.xml b/pom.xml index 6e81fd7..53ca858 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ org.tbplusc boostio-bot - 0.0.1 + 0.1.0 diff --git a/src/main/java/org/tbplusc/app/Main.java b/src/main/java/org/tbplusc/app/Main.java index de179cb..92c2903 100644 --- a/src/main/java/org/tbplusc/app/Main.java +++ b/src/main/java/org/tbplusc/app/Main.java @@ -7,23 +7,62 @@ import org.slf4j.LoggerFactory; import org.tbplusc.app.discordinteraction.DefaultChatState; import org.tbplusc.app.discordinteraction.MessageHandler; +import org.tbplusc.app.discordinteraction.WrappedDiscordMessage; +import org.tbplusc.app.talenthelper.parsers.ITalentProvider; +import org.tbplusc.app.talenthelper.parsers.IcyVeinsRemoteDataProvider; +import org.tbplusc.app.talenthelper.parsers.IcyVeinsTalentProvider; +import org.tbplusc.app.util.EnvWrapper; +import org.tbplusc.app.util.JsonDeserializer; +import org.tbplusc.app.validator.Validator; + +import java.io.IOException; +import java.util.Arrays; public class Main { private static final Logger logger = LoggerFactory.getLogger(Main.class); public static void main(String[] args) { logger.info("Application started"); - final var token = System.getenv("DISCORD_TOKEN"); + registerEnvVariables(); + final var token = EnvWrapper.getValue("DISCORD_TOKEN"); final var client = DiscordClient.create(token); - DefaultChatState.registerDefaultCommands(); - final var messageHandler = new MessageHandler(); + final MessageHandler messageHandler; + try { + messageHandler = createMessageHandler(); + } catch (IOException e) { + logger.error("Can't create message handler", e); + return; + } + logger.info("Message handler is ready"); final var gateway = client.login().block(); if (gateway == null) { logger.error("Can't connect to discord"); return; } gateway.on(MessageCreateEvent.class).map(MessageCreateEvent::getMessage) - .subscribe(messageHandler::handleMessage); + .subscribe(message -> messageHandler + .handleMessage(new WrappedDiscordMessage(message))); gateway.on(DisconnectEvent.class).blockLast(); } + + private static Validator createValidator() throws IOException { + final var heroes = JsonDeserializer.deserializeHeroList(org.tbplusc.app.util.HttpGetter + .getBodyFromUrl("https://hotsapi.net/api/v1/heroes")); + return new Validator(Arrays.asList(heroes.stream().map((hero) -> hero.name) + .toArray(String[]::new))); + } + + private static ITalentProvider createIcyVeinsTalentProvider() { + return new IcyVeinsTalentProvider(new IcyVeinsRemoteDataProvider()); + } + + private static MessageHandler createMessageHandler() throws IOException { + DefaultChatState.registerDefaultCommands(createValidator(), createIcyVeinsTalentProvider()); + return new MessageHandler(); + } + + private static void registerEnvVariables() { + EnvWrapper.registerValue("DISCORD_TOKEN", System.getenv("DISCORD_TOKEN")); + EnvWrapper.registerValue("DISCORD_PREFIX", System.getenv("DISCORD_PREFIX")); + } } diff --git a/src/main/java/org/tbplusc/app/discordinteraction/ChatState.java b/src/main/java/org/tbplusc/app/discordinteraction/ChatState.java index 5c9fcca..bcce9d5 100644 --- a/src/main/java/org/tbplusc/app/discordinteraction/ChatState.java +++ b/src/main/java/org/tbplusc/app/discordinteraction/ChatState.java @@ -1,7 +1,6 @@ package org.tbplusc.app.discordinteraction; -import discord4j.core.object.entity.Message; public interface ChatState { - ChatState handleMessage(Message message); + ChatState handleMessage(WrappedMessage message); } diff --git a/src/main/java/org/tbplusc/app/discordinteraction/DefaultChatState.java b/src/main/java/org/tbplusc/app/discordinteraction/DefaultChatState.java index 5e388d9..dd21e8d 100644 --- a/src/main/java/org/tbplusc/app/discordinteraction/DefaultChatState.java +++ b/src/main/java/org/tbplusc/app/discordinteraction/DefaultChatState.java @@ -1,61 +1,65 @@ package org.tbplusc.app.discordinteraction; -import discord4j.core.object.entity.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.tbplusc.app.talenthelper.HeroConsts; -import org.tbplusc.app.talenthelper.icyveinsparser.IcyVeinsParser; +import org.tbplusc.app.talenthelper.parsers.ITalentProvider; +import org.tbplusc.app.util.EnvWrapper; import org.tbplusc.app.validator.Validator; -import static org.tbplusc.app.discordinteraction.DiscordUtil.getChannelForMessage; - -import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.function.BiFunction; + +/** + * ChatState that responsible for handling commands on first step + */ public class DefaultChatState implements ChatState { private static final Logger logger = LoggerFactory.getLogger(DefaultChatState.class); private final String prefix; - private static final Map> commands = + private static final Map> commands = new HashMap<>(); - public static void registerCommand(String name, BiFunction action) { + public static void registerCommand(String name, BiFunction action) { commands.put(name, action); } - public static void registerDefaultCommands() { + /** + * Register "echo", "authors", "builds" commands. + * @param validator object to find closest word for "builds" command + * @param talentProvider object to get builds for hero + */ + public static void registerDefaultCommands(Validator validator, + ITalentProvider talentProvider) { registerCommand("echo", (args, message) -> { - final var channel = getChannelForMessage(message); - channel.createMessage( - args.equals("") ? "Не могу заэхоть пустую строку" : args - ).block(); + message.respond(args.equals("") ? "Не могу заэхоть пустую строку" : args); return new DefaultChatState(); }); registerCommand("authors", (args, message) -> { - final var channel = getChannelForMessage(message); - channel.createMessage( - "Код писали: Александ Жмышенко, Олег Белахахлий и Semen Зайдельман") - .block(); + message.respond("Код писали: Александ Жмышенко, Олег Белахахлий и Semen Зайдельман"); return new DefaultChatState(); }); registerCommand("build", (args, message) -> { - final var validator = new Validator(); logger.info("Typed hero name: {}", args); - final var possibleHeroNames = validator.getSomeCosestToInput(args, 10); - return new HeroSelectionState(Arrays.asList(possibleHeroNames.clone()), message); + final var possibleHeroNames = validator.getSomeClosestToInput(args, 10); + if (possibleHeroNames[0].distance == 0) { + HeroSelectionState.showHeroBuildToDiscord(message, possibleHeroNames[0].word, + talentProvider); + return new DefaultChatState(); + } + return new HeroSelectionState(Arrays.asList(possibleHeroNames.clone()), message, + talentProvider); }); } public DefaultChatState() { - prefix = System.getenv("DISCORD_PREFIX"); + prefix = EnvWrapper.getValue("DISCORD_PREFIX"); } - @Override - public ChatState handleMessage(Message message) { + @Override public ChatState handleMessage(WrappedMessage message) { final var content = message.getContent(); if (!content.startsWith(prefix)) { return this; diff --git a/src/main/java/org/tbplusc/app/discordinteraction/HeroSelectionState.java b/src/main/java/org/tbplusc/app/discordinteraction/HeroSelectionState.java index 5d72109..ddce7b3 100644 --- a/src/main/java/org/tbplusc/app/discordinteraction/HeroSelectionState.java +++ b/src/main/java/org/tbplusc/app/discordinteraction/HeroSelectionState.java @@ -1,45 +1,61 @@ package org.tbplusc.app.discordinteraction; -import discord4j.core.object.entity.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.tbplusc.app.talenthelper.HeroConsts; -import org.tbplusc.app.talenthelper.icyveinsparser.IcyVeinsParser; +import org.tbplusc.app.talenthelper.parsers.ITalentProvider; +import org.tbplusc.app.talenthelper.parsers.IcyVeinsTalentProvider; +import org.tbplusc.app.validator.WordDistancePair; import java.io.IOException; import java.util.List; -import static org.tbplusc.app.discordinteraction.DiscordUtil.getChannelForMessage; - +/** + * ChatState to allow user to select hero from closest to his text. + */ public class HeroSelectionState implements ChatState { private static final Logger logger = LoggerFactory.getLogger(HeroSelectionState.class); - private final List availableHeroes; + private final List availableHeroes; - private final Message message; + private final WrappedMessage message; + private final ITalentProvider talentProvider; - public HeroSelectionState(List availableHeroes, Message message) { + /** + * ChatState to allow user to select hero from closest to his text. + * @param availableHeroes heroes list provided by Validator + * @param message message with command from user + * @param talentProvider object to get talents from somewhere + */ + public HeroSelectionState(List availableHeroes, WrappedMessage message, + ITalentProvider talentProvider) { this.availableHeroes = availableHeroes; this.message = message; + this.talentProvider = talentProvider; showInitMessage(); } - public void showInitMessage() { - final var channel = getChannelForMessage(message); + private void showInitMessage() { final var heroes = new StringBuilder(); for (var i = 0; i < availableHeroes.size(); i++) { - heroes.append(String.format("%3d. %s \n", i + 1, availableHeroes.get(i))); + heroes.append(String.format("%3d. %s \n", i + 1, availableHeroes.get(i).word)); } - channel.createMessage(String.format("Choice hero (type number): \n ```md\n%s```", heroes)) - .block(); + message.respond(String.format("Choose hero (type number): \n ```md\n%s```", heroes)); } - private static void showHeroBuildToDiscord(Message message, String heroName) { - logger.info("Normalized hero name: {}", heroName); + /** + * Format selected hero build for discord and send it as message response. + * @param message message to respond + * @param heroName + * @param talentProvider object to get talents from somewhere + */ + public static void showHeroBuildToDiscord(WrappedMessage message, String heroName, + ITalentProvider talentProvider) { + final var normalizedHeroName = IcyVeinsTalentProvider.normalizeHeroName(heroName); + logger.info("Normalized hero name: {}", normalizedHeroName); try { - final var builds = IcyVeinsParser.getBuildsByHeroName(heroName); - final var channel = getChannelForMessage(message); - channel.createMessage(String.format("Selected hero is **%s**", heroName)).block(); + final var builds = talentProvider.getBuilds(normalizedHeroName); + message.respond(String.format("Selected hero is **%s**", normalizedHeroName)); builds.getBuilds().stream().map((build) -> { final var talents = new StringBuilder(); for (var i = 0; i < build.getTalents().size(); i++) { @@ -48,20 +64,20 @@ private static void showHeroBuildToDiscord(Message message, String heroName) { } return String.format("**%s**: ```md\n%s```**Description:** %s", build.getName(), talents, build.getDescription()); - }).forEach(build -> channel.createMessage(build).block()); + }).forEach(message::respond); } catch (IOException e) { logger.error("Hero was not loaded", e); } } - @Override public ChatState handleMessage(Message message) { + @Override public ChatState handleMessage(WrappedMessage message) { var number = Integer.parseInt(message.getContent()); - if (number <= 1 || number >= 10) { - getChannelForMessage(message).createMessage("Wrong number").block(); + if (number < 1 || number >= 10) { + message.respond("Wrong number"); return this; } - final var heroName = IcyVeinsParser.normalizeHeroName(availableHeroes.get(number - 1)); - showHeroBuildToDiscord(message, heroName); + final var heroName = availableHeroes.get(number - 1).word; + showHeroBuildToDiscord(message, heroName, talentProvider); return new DefaultChatState(); } } diff --git a/src/main/java/org/tbplusc/app/discordinteraction/MessageHandler.java b/src/main/java/org/tbplusc/app/discordinteraction/MessageHandler.java index 538b6fa..cedfe0c 100644 --- a/src/main/java/org/tbplusc/app/discordinteraction/MessageHandler.java +++ b/src/main/java/org/tbplusc/app/discordinteraction/MessageHandler.java @@ -1,31 +1,26 @@ package org.tbplusc.app.discordinteraction; -import discord4j.core.object.entity.Message; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; public class MessageHandler { private final Map states = new HashMap<>(); + private final ExecutorService threadPool; - public void handleMessage(Message message) { - final var thread = new Thread(() -> processMessage(message), - "Discord command processor"); - thread.start(); + public MessageHandler() { + this.threadPool = Executors.newFixedThreadPool(24); } - private void processMessage(Message message) { - final var authorOptional = message.getAuthor(); - if (authorOptional.isEmpty()) { - throw new NullPointerException("Message had no author"); - } - final var authorId = authorOptional.get().getId(); - final var channel = message.getChannel().block(); - if (channel == null) { - throw new NullPointerException("No channel for the message"); - } - final var channelId = channel.getId(); - final var key = authorId.asString() + channelId.asString(); + public Future handleMessage(WrappedMessage message) { + return threadPool.submit(() -> processMessage(message)); + } + + private void processMessage(WrappedMessage message) { + final var key = message.getContextKey(); if (!states.containsKey(key)) { states.put(key, new DefaultChatState()); } diff --git a/src/main/java/org/tbplusc/app/discordinteraction/WrappedDiscordMessage.java b/src/main/java/org/tbplusc/app/discordinteraction/WrappedDiscordMessage.java new file mode 100644 index 0000000..e09a368 --- /dev/null +++ b/src/main/java/org/tbplusc/app/discordinteraction/WrappedDiscordMessage.java @@ -0,0 +1,36 @@ +package org.tbplusc.app.discordinteraction; + +import discord4j.core.object.entity.Message; +import static org.tbplusc.app.discordinteraction.DiscordUtil.getChannelForMessage; + +public class WrappedDiscordMessage implements WrappedMessage { + private final Message message; + + public WrappedDiscordMessage(Message message) { + this.message = message; + } + + @Override + public String getContextKey() { + final var authorOptional = message.getAuthor(); + if (authorOptional.isEmpty()) { + throw new NullPointerException("Message had no author"); + } + final var authorId = authorOptional.get().getId(); + final var channel = message.getChannel().block(); + if (channel == null) { + throw new NullPointerException("No channel for the message"); + } + final var channelId = channel.getId(); + return authorId.asString() + channelId.asString(); + } + + @Override public String getContent() { + return message.getContent(); + } + + @Override public void respond(String text) { + var channel = getChannelForMessage(message); + channel.createMessage(text).block(); + } +} diff --git a/src/main/java/org/tbplusc/app/discordinteraction/WrappedMessage.java b/src/main/java/org/tbplusc/app/discordinteraction/WrappedMessage.java new file mode 100644 index 0000000..738669e --- /dev/null +++ b/src/main/java/org/tbplusc/app/discordinteraction/WrappedMessage.java @@ -0,0 +1,9 @@ +package org.tbplusc.app.discordinteraction; + +public interface WrappedMessage { + String getContextKey(); + + String getContent(); + + void respond(String text); +} diff --git a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsBuild.java b/src/main/java/org/tbplusc/app/talenthelper/HeroBuild.java similarity index 79% rename from src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsBuild.java rename to src/main/java/org/tbplusc/app/talenthelper/HeroBuild.java index 5185350..a8c5895 100644 --- a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsBuild.java +++ b/src/main/java/org/tbplusc/app/talenthelper/HeroBuild.java @@ -1,14 +1,14 @@ -package org.tbplusc.app.talenthelper.icyveinsparser; +package org.tbplusc.app.talenthelper; import java.util.ArrayList; import java.util.List; -public class IcyVeinsBuild { +public class HeroBuild { private final String name; private final String description; private final List talents; - public IcyVeinsBuild(String name, String description, List talents) { + public HeroBuild(String name, String description, List talents) { this.name = name; this.description = description; this.talents = new ArrayList<>(talents); diff --git a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsHeroBuilds.java b/src/main/java/org/tbplusc/app/talenthelper/HeroBuilds.java similarity index 60% rename from src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsHeroBuilds.java rename to src/main/java/org/tbplusc/app/talenthelper/HeroBuilds.java index 2915a65..bba90cb 100644 --- a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsHeroBuilds.java +++ b/src/main/java/org/tbplusc/app/talenthelper/HeroBuilds.java @@ -1,13 +1,13 @@ -package org.tbplusc.app.talenthelper.icyveinsparser; +package org.tbplusc.app.talenthelper; import java.util.ArrayList; import java.util.List; -public class IcyVeinsHeroBuilds { +public class HeroBuilds { private final String heroName; - private final List builds; + private final List builds; - public IcyVeinsHeroBuilds(String heroName, List builds) { + public HeroBuilds(String heroName, List builds) { this.heroName = heroName; this.builds = new ArrayList<>(builds); } @@ -16,7 +16,7 @@ public String getHeroName() { return heroName; } - public List getBuilds() { + public List getBuilds() { return new ArrayList<>(builds); } diff --git a/src/main/java/org/tbplusc/app/talenthelper/HeroConsts.java b/src/main/java/org/tbplusc/app/talenthelper/HeroConsts.java index 7399270..a07d267 100644 --- a/src/main/java/org/tbplusc/app/talenthelper/HeroConsts.java +++ b/src/main/java/org/tbplusc/app/talenthelper/HeroConsts.java @@ -5,5 +5,5 @@ private HeroConsts() throws IllegalAccessException { throw new IllegalAccessException("Utility class"); } - public final static int[] HERO_TALENTS_LEVELS = new int[] {1, 4, 7, 10, 13, 16, 20}; + public static final int[] HERO_TALENTS_LEVELS = new int[] {1, 4, 7, 10, 13, 16, 20}; } diff --git a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsParser.java b/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsParser.java deleted file mode 100644 index a06a125..0000000 --- a/src/main/java/org/tbplusc/app/talenthelper/icyveinsparser/IcyVeinsParser.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.tbplusc.app.talenthelper.icyveinsparser; - -import java.io.IOException; -import java.util.List; -import java.util.ArrayList; - -import org.tbplusc.app.util.HttpGetter; - -public class IcyVeinsParser { - private static final String ADDRESS_PREFIX = "https://www.icy-veins.com/heroes/"; - private static final String ADDRESS_POSTFIX = "-talents"; - - private IcyVeinsParser() { - throw new IllegalStateException("Utility class"); - } - - public static IcyVeinsHeroBuilds getBuildsByHeroName(String heroName) throws IOException { - var talentPage = getDocumentFromHeroName(heroName); - return new IcyVeinsHeroBuilds(heroName, getBuildsListFromDocument(talentPage)); - } - - public static List getBuildsListFromDocument(org.jsoup.nodes.Document document) { - var outputBuilds = new ArrayList(); - var buildElems = document.getElementsByClass("heroes_builds").first() - .getElementsByClass("heroes_build"); - - for (var build : buildElems) { - var buildName = build.getElementsByClass("toc_no_parsing").first().text(); - var buildDesc = build.getElementsByClass("heroes_build_text").first().text(); - var talents = new ArrayList(); - build.getElementsByClass("heroes_build_talent_tier").forEach(tier -> { - var talent = tier.selectFirst("img").attr("alt"); - talent = talent.substring(0, talent.lastIndexOf(" ")); - talents.add(talent); - }); - outputBuilds.add(new IcyVeinsBuild(buildName, buildDesc, talents)); - } - - return outputBuilds; - - } - - public static String normalizeHeroName(String heroName) { - return heroName.replace(".", "").replace(" ", "-").replace("'", "").toLowerCase(); - } - - private static org.jsoup.nodes.Document getDocumentFromHeroName(String heroName) - throws IOException { - return HttpGetter.getDocumentFromUrl(ADDRESS_PREFIX + heroName + ADDRESS_POSTFIX); - } -} diff --git a/src/main/java/org/tbplusc/app/talenthelper/parsers/IIcyVeinsDataProvider.java b/src/main/java/org/tbplusc/app/talenthelper/parsers/IIcyVeinsDataProvider.java new file mode 100644 index 0000000..a955d40 --- /dev/null +++ b/src/main/java/org/tbplusc/app/talenthelper/parsers/IIcyVeinsDataProvider.java @@ -0,0 +1,7 @@ +package org.tbplusc.app.talenthelper.parsers; + +import java.io.IOException; + +public interface IIcyVeinsDataProvider { + public org.jsoup.nodes.Document getDocument(String heroName) throws IOException; +} diff --git a/src/main/java/org/tbplusc/app/talenthelper/parsers/ITalentProvider.java b/src/main/java/org/tbplusc/app/talenthelper/parsers/ITalentProvider.java new file mode 100644 index 0000000..c44828f --- /dev/null +++ b/src/main/java/org/tbplusc/app/talenthelper/parsers/ITalentProvider.java @@ -0,0 +1,8 @@ +package org.tbplusc.app.talenthelper.parsers; + +import java.io.IOException; +import org.tbplusc.app.talenthelper.HeroBuilds; + +public interface ITalentProvider { + public HeroBuilds getBuilds(String heroName) throws IOException; +} diff --git a/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsRemoteDataProvider.java b/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsRemoteDataProvider.java new file mode 100644 index 0000000..0ca38ee --- /dev/null +++ b/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsRemoteDataProvider.java @@ -0,0 +1,13 @@ +package org.tbplusc.app.talenthelper.parsers; + +import java.io.IOException; +import org.tbplusc.app.util.HttpGetter; + +public class IcyVeinsRemoteDataProvider implements IIcyVeinsDataProvider { + private static final String ADDRESS_PREFIX = "https://www.icy-veins.com/heroes/"; + private static final String ADDRESS_POSTFIX = "-talents"; + + public org.jsoup.nodes.Document getDocument(String heroName) throws IOException { + return HttpGetter.getDocumentFromUrl(ADDRESS_PREFIX + heroName + ADDRESS_POSTFIX); + } +} diff --git a/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsTalentProvider.java b/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsTalentProvider.java new file mode 100644 index 0000000..c677bfa --- /dev/null +++ b/src/main/java/org/tbplusc/app/talenthelper/parsers/IcyVeinsTalentProvider.java @@ -0,0 +1,43 @@ +package org.tbplusc.app.talenthelper.parsers; + +import java.io.IOException; +import java.util.ArrayList; + +import org.tbplusc.app.talenthelper.HeroBuild; +import org.tbplusc.app.talenthelper.HeroBuilds; + +public class IcyVeinsTalentProvider implements ITalentProvider { + private final IIcyVeinsDataProvider dataProvider; + + public IcyVeinsTalentProvider(IIcyVeinsDataProvider dataProvider) { + this.dataProvider = dataProvider; + } + + public HeroBuilds getBuilds(String heroName) throws IOException { + var document = this.dataProvider.getDocument(heroName); + var hero = document.getElementsByTag("h1").first().text(); + var outputBuilds = new ArrayList(); + var buildElems = document.getElementsByClass("heroes_builds").first() + .getElementsByClass("heroes_build"); + + for (var build : buildElems) { + var buildName = build.getElementsByClass("toc_no_parsing").first().text(); + var buildDesc = build.getElementsByClass("heroes_build_text").first().text(); + var talents = new ArrayList(); + build.getElementsByClass("heroes_build_talent_tier").forEach(tier -> { + var talent = tier.selectFirst("img").attr("alt"); + talent = talent.substring(0, talent.lastIndexOf(" ")); + talents.add(talent); + }); + outputBuilds.add(new HeroBuild(buildName, buildDesc, talents)); + } + + return new HeroBuilds(hero, outputBuilds); + + } + + public static String normalizeHeroName(String heroName) { + return heroName.replace(".", "").replace(" ", "-").replace("'", "").replace("ú", "u") + .toLowerCase(); + } +} diff --git a/src/main/java/org/tbplusc/app/util/EnvWrapper.java b/src/main/java/org/tbplusc/app/util/EnvWrapper.java new file mode 100644 index 0000000..96d9da1 --- /dev/null +++ b/src/main/java/org/tbplusc/app/util/EnvWrapper.java @@ -0,0 +1,19 @@ +package org.tbplusc.app.util; + +import java.util.HashMap; +import java.util.Map; + +/** + * Acts as middleware for system environment. + */ +public class EnvWrapper { + private static final Map registeredValues = new HashMap<>(); + + public static void registerValue(String key, String value) { + registeredValues.put(key, value); + } + + public static String getValue(String key) { + return registeredValues.get(key); + } +} diff --git a/src/main/java/org/tbplusc/app/validator/Validator.java b/src/main/java/org/tbplusc/app/validator/Validator.java index 17fee31..0f9167b 100644 --- a/src/main/java/org/tbplusc/app/validator/Validator.java +++ b/src/main/java/org/tbplusc/app/validator/Validator.java @@ -2,41 +2,42 @@ import org.apache.commons.text.similarity.LevenshteinDistance; -import java.util.Arrays; import java.util.Comparator; +import java.util.List; public class Validator { - private final String[] charactersNames; + private final List charactersNames; private final LevenshteinDistance levenshteinComparer = new LevenshteinDistance(); - private Comparator getComporator(String s){ - return Comparator.comparing(t -> levenshteinComparer.apply(t.toLowerCase(), s)); + private Comparator getComporator(String s) { + return Comparator.comparing(t -> applyComparing(t.toLowerCase(), s)); } - public Validator(){ - charactersNames = new String[]{"Mei", "Deathwing", "Qhira", "Anduin", "Imperius", "Orphea", "Mal'Ganis", "Mephisto", - "Whitemane", "Yrel", "Deckard", "Fenix", "Maiev", "Blaze", "Hanzo", "Alexstrasza", "Junkrat", - "Ana", "Kel'Thuzad", "Garrosh", "Stukov", "Malthael", "D.Va", "Genji", "Cassia", "Probius", - "Lucio", "Valeera", "Zul'jin", "Ragnaros", "Varian", "Samuro", "Zarya", "Alarak", "Auriel", - "Gul'dan", "Medivh", "Chromie", "Tracer", "Dehaka", "Xul", "Li-Ming", "Greymane", "Lunara", "Cho", - "Gall", "Artanis", "Lt. Morales", "Rexxar", "Kharazim", "Leoric", "The Butcher", "Johanna", "Kael'thas", - "Sylvanas", "The Lost Vikings", "Thrall", "Jaina", "Azmodan", "Anub'arak", "Chen", "Rehgar", "Zagara", - "Murky", "Brightwing", "Li Li", "Tychus", "Abathur", "Arthas", "Diablo", "Illidan", "Kerrigan", - "Malfurion", "Muradin", "Nazeebo", "Nova", "Raynor", "Sgt. Hammer", "Sonya", "Tyrael", "Tyrande", - "Uther", "Valla", "Zeratul", "E.T.C.", "Falstad", "Gazlowe", "Stitches", "Tassadar"}; + private int applyComparing(String s1, String s2) { + return levenshteinComparer.apply(s1, s2); } - public String getClosestToInput(String userInput){ - return Arrays.stream(charactersNames) - .min(getComporator(userInput)) + public Validator(List heroes) throws IllegalArgumentException { + if (heroes == null || heroes.isEmpty()) { + throw new IllegalArgumentException(); + } + charactersNames = heroes; + } + + public String getClosestToInput(String userInput) { + var loweredInput = userInput.toLowerCase(); + return charactersNames.stream() + .min(getComporator(loweredInput)) .get(); } - public String[] getSomeCosestToInput(String userInput, int length){ - return Arrays.stream(charactersNames) - .sorted(getComporator(userInput)) + public WordDistancePair[] getSomeClosestToInput(String userInput, int length) { + var loweredInput = userInput.toLowerCase(); + return charactersNames.stream() + .map(s -> new WordDistancePair(s, applyComparing(s.toLowerCase(), loweredInput))) + .sorted(Comparator.comparingInt(s -> s.distance)) .limit(length) - .toArray(String[]::new); + .toArray(WordDistancePair[]::new); } } diff --git a/src/main/java/org/tbplusc/app/validator/WordDistancePair.java b/src/main/java/org/tbplusc/app/validator/WordDistancePair.java new file mode 100644 index 0000000..1bdb8fe --- /dev/null +++ b/src/main/java/org/tbplusc/app/validator/WordDistancePair.java @@ -0,0 +1,11 @@ +package org.tbplusc.app.validator; + +public class WordDistancePair { + public final String word; + public final int distance; + + public WordDistancePair(String word, int distance) { + this.distance = distance; + this.word = word; + } +} diff --git a/src/test/java/org/tbplusc/app/discordinteraction/MessageHandlerTests.java b/src/test/java/org/tbplusc/app/discordinteraction/MessageHandlerTests.java new file mode 100644 index 0000000..6b0101e --- /dev/null +++ b/src/test/java/org/tbplusc/app/discordinteraction/MessageHandlerTests.java @@ -0,0 +1,140 @@ +package org.tbplusc.app.discordinteraction; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.tbplusc.app.talenthelper.HeroBuild; +import org.tbplusc.app.talenthelper.HeroBuilds; +import org.tbplusc.app.talenthelper.parsers.ITalentProvider; +import org.tbplusc.app.util.EnvWrapper; +import org.tbplusc.app.validator.Validator; +import org.tbplusc.app.validator.WordDistancePair; + +import java.awt.*; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public class MessageHandlerTests { + private Validator validatorMock; + private ITalentProvider talentHelperMock; + + + private static class TestDiscordMessage implements WrappedMessage { + + private final Consumer callback; + private final String content; + + public TestDiscordMessage(Consumer callback, String content) { + this.callback = callback; + this.content = content; + } + + @Override public String getContextKey() { + return "AA"; + } + + @Override public String getContent() { + return content; + } + + @Override public void respond(String text) { + callback.accept(text); + } + } + + + private MessageHandler messageHandler; + + @Before public void setUp() { + messageHandler = new MessageHandler(); + validatorMock = Mockito.mock(Validator.class); + talentHelperMock = Mockito.mock(ITalentProvider.class); + DefaultChatState.registerDefaultCommands(validatorMock, talentHelperMock); + EnvWrapper.registerValue("DISCORD_PREFIX", "!"); + } + + @Test public void testHandlingCommand() throws ExecutionException, InterruptedException { + var called = new AtomicBoolean(false); + DefaultChatState.registerCommand("test", (args, message) -> { + called.set(true); + message.respond("TEST"); + return new DefaultChatState(); + }); + + var messageStub = new TestDiscordMessage((text) -> { + Assert.assertEquals(text, "TEST"); + }, "!test"); + + messageHandler.handleMessage(messageStub).get(); + + + Assert.assertTrue(called.get()); + } + + @Test public void testBuildsCommandWithExactMatch() + throws IOException, ExecutionException, InterruptedException { + Mockito.when(validatorMock.getSomeClosestToInput("test", 10)) + .thenReturn(new WordDistancePair[] {new WordDistancePair("test", 0), + new WordDistancePair("test1", 1),}); + Mockito.when(talentHelperMock.getBuilds("test")).thenReturn(new HeroBuilds("test", + List.of(new HeroBuild("main", "main", + List.of("a", "b", "c", "d", "CHECK", "f", "g"))))); + var results = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(results::add, "!build test")).get(); + + Assert.assertTrue(results.get(0).contains("test")); + Assert.assertTrue(results.get(1).contains("main")); + Assert.assertTrue(results.get(1).contains("CHECK")); + } + + @Test public void testBuildsCommandWithNonExactMatch() + throws IOException, ExecutionException, InterruptedException { + Mockito.when(validatorMock.getSomeClosestToInput("test1", 10)) + .thenReturn(new WordDistancePair[] {new WordDistancePair("test2", 1), + new WordDistancePair("test23", 2),}); + Mockito.when(talentHelperMock.getBuilds("test23")).thenReturn(new HeroBuilds("test23", + List.of(new HeroBuild("main", "main", + List.of("a", "b", "c", "d", "CHECK", "f", "g"))))); + + var firstResults = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(firstResults::add, "!build test1")) + .get(); + + Assert.assertTrue(firstResults.get(0).contains("test2")); + Assert.assertTrue(firstResults.get(0).contains("test23")); + + var secondResults = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(secondResults::add, "2")).get(); + + Assert.assertTrue(secondResults.get(0).contains("test23")); + Assert.assertTrue(secondResults.get(1).contains("CHECK")); + } + + @Test public void testIgnoreNonCommands() throws ExecutionException, InterruptedException { + var results = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(results::add, "KEKLOLARBIDOL")).get(); + + Assert.assertEquals(0, results.size()); + } + + @Test public void testEcho() throws ExecutionException, InterruptedException { + var results = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(results::add, "!echo KEKLOLARBIDOL")) + .get(); + + Assert.assertEquals(results.get(0), "KEKLOLARBIDOL"); + } + + @Test public void testWrongCommand() throws ExecutionException, InterruptedException { + var results = new ArrayList(); + messageHandler.handleMessage(new TestDiscordMessage(results::add, "!keklolarbidol7623765")) + .get(); + + Assert.assertEquals(0, results.size()); + } +} diff --git a/src/test/java/org/tbplusc/app/talenthelpertests/icyveinsparsertests/IcyVeinsParserTests.java b/src/test/java/org/tbplusc/app/talenthelpertests/icyveinsparsertests/IcyVeinsParserTests.java index 5e580ed..ce2bd12 100644 --- a/src/test/java/org/tbplusc/app/talenthelpertests/icyveinsparsertests/IcyVeinsParserTests.java +++ b/src/test/java/org/tbplusc/app/talenthelpertests/icyveinsparsertests/IcyVeinsParserTests.java @@ -1,24 +1,27 @@ package org.tbplusc.app.talenthelpertests.icyveinsparsertests; import org.junit.Test; +import org.tbplusc.app.talenthelper.parsers.IIcyVeinsDataProvider; +import org.tbplusc.app.talenthelper.parsers.IcyVeinsTalentProvider; import org.junit.Before; +import org.mockito.Mockito; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; -import java.io.File; import java.io.IOException; import org.jsoup.Jsoup; -import org.tbplusc.app.talenthelper.icyveinsparser.IcyVeinsParser; - public class IcyVeinsParserTests { private static final String TEST_HTML_PATH = "html"; private org.jsoup.nodes.Document docWithOneBuild; private org.jsoup.nodes.Document docWithMultipleBuilds; + private IcyVeinsTalentProvider parser; @Before public void setUp() throws IOException { + var mock = Mockito.mock(IIcyVeinsDataProvider.class); + parser = new IcyVeinsTalentProvider(mock); docWithOneBuild = Jsoup.parse(new String(getClass().getClassLoader() .getResourceAsStream(TEST_HTML_PATH + "/icyveins_example_build.html") .readAllBytes())); @@ -26,33 +29,41 @@ public void setUp() throws IOException { .getResourceAsStream( TEST_HTML_PATH + "/icyveins_example_multiple_builds.html") .readAllBytes())); + Mockito.when(mock.getDocument("testheroone")).thenReturn(docWithOneBuild); + Mockito.when(mock.getDocument("testherotwo")).thenReturn(docWithMultipleBuilds); } @Test - public void testCorrectBuildNumbers() { - var oneBuild = IcyVeinsParser.getBuildsListFromDocument(docWithOneBuild); - var twoBuilds = IcyVeinsParser.getBuildsListFromDocument(docWithMultipleBuilds); - assertEquals(1, oneBuild.size()); - assertEquals(2, twoBuilds.size()); + public void testCorrectBuildSize() throws IOException { + var oneBuild = parser.getBuilds("testheroone"); + var twoBuilds = parser.getBuilds("testherotwo"); + assertEquals(1, oneBuild.getBuilds().size()); + assertEquals(2, twoBuilds.getBuilds().size()); } @Test - public void testCorrectBuildName() { - var buildToTest = IcyVeinsParser.getBuildsListFromDocument(docWithMultipleBuilds).get(1); + public void testCorrectBuildName() throws IOException { + var buildToTest = parser.getBuilds("testherotwo").getBuilds().get(1); assertEquals("Situational Test Build", buildToTest.getName()); } @Test - public void testCorrectOrderOfTalents() { - var buildToTest = IcyVeinsParser.getBuildsListFromDocument(docWithOneBuild).get(0); + public void testCorrectOrderOfTalents() throws IOException { + var buildToTest = parser.getBuilds("testheroone").getBuilds().get(0); assertArrayEquals(new String[] {"Single Talent 1", "Single Talent 2", "Single Talent 3", "Single Talent 4", "Single Talent 5", "Single Talent 6", "Single Talent 7"}, buildToTest.getTalents().toArray()); } @Test - public void testCorrectDescription() { - var buildToTest = IcyVeinsParser.getBuildsListFromDocument(docWithMultipleBuilds).get(0); + public void testCorrectDescription() throws IOException { + var buildToTest = parser.getBuilds("testherotwo").getBuilds().get(0); assertEquals("Generic multi-purpose build for test hero.", buildToTest.getDescription()); } + + @Test + public void testCorrectHeroName() throws IOException { + var heroBuildsToTest = parser.getBuilds("testheroone"); + assertEquals("Hero with one build", heroBuildsToTest.getHeroName()); + } } diff --git a/src/test/java/org/tbplusc/app/validatortests/ValidatorTests.java b/src/test/java/org/tbplusc/app/validatortests/ValidatorTests.java index a1de20d..4a12075 100644 --- a/src/test/java/org/tbplusc/app/validatortests/ValidatorTests.java +++ b/src/test/java/org/tbplusc/app/validatortests/ValidatorTests.java @@ -1,13 +1,15 @@ package org.tbplusc.app.validatortests; import org.junit.Test; -import org.tbplusc.app.Main; import org.tbplusc.app.validator.Validator; +import java.util.ArrayList; +import java.util.Arrays; + import static org.junit.Assert.assertEquals; public class ValidatorTests { - protected Validator validator = new Validator(); + protected Validator validator = new Validator(new ArrayList<>(Arrays.asList("Mei", "Rexxar", "Abathur", "Chen", "Cho", "Raynor"))); @Test public void testRetursCorrectAnswerOnCompleteInput() { diff --git a/src/test/resources/html/icyveins_example_build.html b/src/test/resources/html/icyveins_example_build.html index 2a14dc1..7d32165 100644 --- a/src/test/resources/html/icyveins_example_build.html +++ b/src/test/resources/html/icyveins_example_build.html @@ -2,6 +2,7 @@ +

Hero with one build

diff --git a/src/test/resources/html/icyveins_example_multiple_builds.html b/src/test/resources/html/icyveins_example_multiple_builds.html index 9242e9b..c008e23 100644 --- a/src/test/resources/html/icyveins_example_multiple_builds.html +++ b/src/test/resources/html/icyveins_example_multiple_builds.html @@ -2,6 +2,7 @@ +

Hero with two builds