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