diff --git a/FactorioBlueprintStringRenderer/.gitignore b/FactorioBlueprintStringRenderer/.gitignore
index f57b9b3..585e64d 100644
--- a/FactorioBlueprintStringRenderer/.gitignore
+++ b/FactorioBlueprintStringRenderer/.gitignore
@@ -3,3 +3,4 @@
/blueprint-string_4.0.0.zip
/test.png
/config.json
+/redditCache.json
diff --git a/FactorioBlueprintStringRenderer/config.template.json b/FactorioBlueprintStringRenderer/config.template.json
index acae349..0d2e461 100644
--- a/FactorioBlueprintStringRenderer/config.template.json
+++ b/FactorioBlueprintStringRenderer/config.template.json
@@ -1,7 +1,16 @@
{
"discord_bot_token": "",
- "pastebin_developer_api_key": "",
- "factorio": "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio",
- "type_schema": "https://raw.githubusercontent.com/demodude4u/factorio-tools/master/schema.json",
- "__type_schema": "https://raw.githubusercontent.com/jcranmer/factorio-tools/master/schema.json"
+ "reddit": {
+ "credentials": {
+ "username": "",
+ "password": "",
+ "client_id": "",
+ "client_secret": ""
+ },
+ "subreddit": "",
+ "summon_keyword": "!blueprint",
+ "refresh_seconds": 20,
+ "age_limit_hours": 24
+ },
+ "factorio": "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Factorio"
}
\ No newline at end of file
diff --git a/FactorioBlueprintStringRenderer/pom.xml b/FactorioBlueprintStringRenderer/pom.xml
index f131e96..ea1fd08 100644
--- a/FactorioBlueprintStringRenderer/pom.xml
+++ b/FactorioBlueprintStringRenderer/pom.xml
@@ -65,5 +65,10 @@
fluent-hc
4.5.3
+
+ net.dean.jraw
+ JRAW
+ 0.9.0
+
\ No newline at end of file
diff --git a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/TaskReporting.java b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/TaskReporting.java
index f0af4ce..ba09369 100644
--- a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/TaskReporting.java
+++ b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/TaskReporting.java
@@ -25,19 +25,24 @@ public class TaskReporting {
private Optional context = Optional.empty();
private final List warnings = new ArrayList<>();
private final List exceptions = new ArrayList<>();
- private final List imageUrls = new ArrayList<>();
- private final List downloadUrls = new ArrayList<>();
+ private final List images = new ArrayList<>();
+ private final List downloads = new ArrayList<>();
+ private final List links = new ArrayList<>();
- public void addDownloadURL(String downloadURL) {
- downloadUrls.add(downloadURL);
+ public void addDownload(String url) {
+ downloads.add(url);
}
public synchronized void addException(Exception e) {
exceptions.add(e);
}
- public void addImageURL(String imageURL) {
- imageUrls.add(imageURL);
+ public void addImage(String url) {
+ images.add(url);
+ }
+
+ public void addLink(String url) {
+ links.add(url);
}
public void addWarning(String warning) {
@@ -52,16 +57,16 @@ public class TaskReporting {
return debug;
}
- public List getDownloadURLs() {
- return downloadUrls;
+ public List getDownloads() {
+ return downloads;
}
public List getExceptions() {
return exceptions;
}
- public List getImageURLs() {
- return imageUrls;
+ public List getImages() {
+ return images;
}
public Level getLevel() {
@@ -77,6 +82,10 @@ public class TaskReporting {
return Level.INFO;
}
+ public List getLinks() {
+ return links;
+ }
+
public List getWarnings() {
return warnings;
}
diff --git a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotDiscordService.java b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotDiscordService.java
index e513a75..5f015c8 100644
--- a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotDiscordService.java
+++ b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotDiscordService.java
@@ -55,14 +55,12 @@ import net.dv8tion.jda.core.events.message.MessageReceivedEvent;
public class BlueprintBotDiscordService extends AbstractIdleService {
+ private static final int MADEUP_NUMBER_FROM_AROUND_5_IN_THE_MORNING = 200;
+
private static final String USERID_DEMOD = "100075603016814592";
private static final Pattern debugPattern = Pattern.compile("DEBUG:([A-Za-z0-9_]+)");
- public static void main(String[] args) {
- new BlueprintBotDiscordService().startAsync();
- }
-
private DiscordBot bot;
private CommandHandler createDataRawCommandHandler(Function> query) {
@@ -185,7 +183,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
processBlueprints(BlueprintFinder.search(content, reporting), event, reporting);
}
- if (reporting.getImageURLs().isEmpty() && reporting.getDownloadURLs().isEmpty()) {
+ if (reporting.getImages().isEmpty() && reporting.getDownloads().isEmpty()) {
event.getChannel().sendMessage("I can't seem to find any blueprints. :frowning:").complete();
}
sendReportToDemod(event, reporting);
@@ -202,7 +200,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
if (results.size() == 1) {
URL url = WebUtils.uploadToHostingService("blueprint.json", bytes);
event.getChannel().sendMessage("Blueprint JSON: " + url.toString()).complete();
- reporting.addDownloadURL(url.toString());
+ reporting.addLink(url.toString());
} else {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(baos)) {
@@ -219,7 +217,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
byte[] zipData = baos.toByteArray();
Message response = event.getChannel().sendFile(zipData, "blueprint JSON files.zip", null)
.complete();
- reporting.addDownloadURL(response.getAttachments().get(0).getUrl());
+ reporting.addDownload(response.getAttachments().get(0).getUrl());
} catch (IOException e) {
reporting.addException(e);
}
@@ -229,7 +227,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
}
}
- if (reporting.getImageURLs().isEmpty() && reporting.getDownloadURLs().isEmpty()) {
+ if (reporting.getImages().isEmpty() && reporting.getDownloads().isEmpty()) {
event.getChannel().sendMessage("I can't seem to find any blueprints. :frowning:").complete();
}
sendReportToDemod(event, reporting);
@@ -247,7 +245,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
byte[] imageData = generateDiscordFriendlyPNGImage(image);
Message message = event.getChannel()
.sendFile(new ByteArrayInputStream(imageData), "blueprint.png", null).complete();
- reporting.addImageURL(message.getAttachments().get(0).getUrl());
+ reporting.addImage(message.getAttachments().get(0).getUrl());
} else {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(baos)) {
@@ -269,7 +267,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
Message message = event.getChannel()
.sendFile(new ByteArrayInputStream(zipData), "blueprint book images.zip", null)
.complete();
- reporting.addDownloadURL(message.getAttachments().get(0).getUrl());
+ reporting.addDownload(message.getAttachments().get(0).getUrl());
}
}
} catch (Exception e) {
@@ -288,26 +286,31 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
URL url = WebUtils.uploadToHostingService(category + "_" + name + "_dump_" + FBSR.getVersion() + ".txt",
baos.toByteArray());
event.getChannel().sendMessage(category + " " + name + " lua dump: " + url.toString()).complete();
- reporting.addDownloadURL(url.toString());
+ reporting.addLink(url.toString());
}
}
- private void sendReportToDemod(MessageReceivedEvent event, TaskReporting reporting) {
- Optional context = reporting.getContext();
- List exceptions = reporting.getExceptions();
- List warnings = reporting.getWarnings();
- List imageUrls = reporting.getImageURLs();
- List downloadUrls = reporting.getDownloadURLs();
-
- if (!exceptions.isEmpty()) {
+ public void sendReportToDemod(MessageReceivedEvent event, TaskReporting reporting) {
+ if (!reporting.getExceptions().isEmpty()) {
event.getChannel()
.sendMessage(
"There was a problem completing your request. I have contacted my programmer to fix it for you!")
.complete();
}
+ sendReportToDemod(getReadableAddress(event), event.getAuthor().getEffectiveAvatarUrl(), reporting);
+ }
+
+ public void sendReportToDemod(String author, String authorURL, TaskReporting reporting) {
+ Optional context = reporting.getContext();
+ List exceptions = reporting.getExceptions();
+ List warnings = reporting.getWarnings();
+ List images = reporting.getImages();
+ List links = reporting.getLinks();
+ List downloads = reporting.getDownloads();
+
EmbedBuilder builder = new EmbedBuilder();
- builder.setAuthor(getReadableAddress(event), null, event.getAuthor().getEffectiveAvatarUrl());
+ builder.setAuthor(author, null, authorURL);
builder.setTimestamp(Instant.now());
Level level = reporting.getLevel();
@@ -315,23 +318,24 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
builder.setColor(level.getColor());
}
- if (context.isPresent() && context.get().length() <= 200) {
+ if (context.isPresent() && context.get().length() <= MADEUP_NUMBER_FROM_AROUND_5_IN_THE_MORNING) {
builder.addField("Context", context.get(), false);
context = Optional.empty();// XXX Being lazy at 5am
}
- boolean firstImage = true;
- for (String imageUrl : imageUrls) {
- if (firstImage) {
- firstImage = false;
- builder.setImage(imageUrl);
- } else {
- builder.addField("Additional Image", imageUrl, true);
- }
+ if (!links.isEmpty()) {
+ builder.addField("Link(s)", links.stream().collect(Collectors.joining("\n")), false);
}
- for (String downloadUrl : downloadUrls) {
- builder.addField("Download", downloadUrl, true);
+ if (!images.isEmpty()) {
+ builder.setImage(images.get(0));
+ }
+ if (images.size() > 1) {
+ builder.addField("Additional Image(s)", images.stream().skip(1).collect(Collectors.joining("\n")), false);
+ }
+
+ if (!downloads.isEmpty()) {
+ builder.addField("Download(s)", downloads.stream().collect(Collectors.joining("\n")), false);
}
Multiset uniqueWarnings = LinkedHashMultiset.create(warnings);
@@ -367,7 +371,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
false);
}
- PrivateChannel privateChannel = event.getJDA().getUserById(USERID_DEMOD).openPrivateChannel().complete();
+ PrivateChannel privateChannel = bot.getJDA().getUserById(USERID_DEMOD).openPrivateChannel().complete();
privateChannel.sendMessage(builder.build()).complete();
if (context.isPresent()) {
@@ -381,6 +385,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
@Override
protected void shutDown() throws Exception {
+ ServiceFinder.removeService(this);
bot.stopAsync().awaitTerminated();
}
@@ -437,6 +442,7 @@ public class BlueprintBotDiscordService extends AbstractIdleService {
bot.startAsync().awaitRunning();
+ ServiceFinder.addService(this);
} catch (Exception e) {
e.printStackTrace();
}
diff --git a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotRedditService.java b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotRedditService.java
new file mode 100644
index 0000000..28ce102
--- /dev/null
+++ b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/BlueprintBotRedditService.java
@@ -0,0 +1,334 @@
+package com.demod.fbsr.app;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import javax.imageio.ImageIO;
+
+import org.json.JSONObject;
+
+import com.demod.factorio.Config;
+import com.demod.factorio.Utils;
+import com.demod.fbsr.Blueprint;
+import com.demod.fbsr.BlueprintFinder;
+import com.demod.fbsr.BlueprintStringData;
+import com.demod.fbsr.FBSR;
+import com.demod.fbsr.RenderUtils;
+import com.demod.fbsr.TaskReporting;
+import com.demod.fbsr.WebUtils;
+import com.google.common.base.Throwables;
+import com.google.common.util.concurrent.AbstractScheduledService;
+
+import javafx.util.Pair;
+import net.dean.jraw.ApiException;
+import net.dean.jraw.RedditClient;
+import net.dean.jraw.http.NetworkException;
+import net.dean.jraw.http.UserAgent;
+import net.dean.jraw.http.oauth.Credentials;
+import net.dean.jraw.http.oauth.OAuthData;
+import net.dean.jraw.http.oauth.OAuthException;
+import net.dean.jraw.managers.AccountManager;
+import net.dean.jraw.models.Comment;
+import net.dean.jraw.models.CommentNode;
+import net.dean.jraw.models.Listing;
+import net.dean.jraw.models.Submission;
+import net.dean.jraw.paginators.CommentStream;
+import net.dean.jraw.paginators.Sorting;
+import net.dean.jraw.paginators.SubredditPaginator;
+import net.dean.jraw.paginators.TimePeriod;
+
+public class BlueprintBotRedditService extends AbstractScheduledService {
+
+ private static final File CACHE_FILE = new File("redditCache.json");
+ private static final String REDDIT_AUTHOR_URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Reddit.svg/64px-Reddit.svg.png";
+
+ private JSONObject configJson;
+ private String myUserName;
+
+ private RedditClient reddit;
+ private AccountManager account;
+ private Credentials credentials;
+ private OAuthData authData;
+
+ private void ensureValidAccessToken() throws NetworkException, OAuthException {
+ if (System.currentTimeMillis() > authData.getExpirationDate().getTime()) {
+ authData = reddit.getOAuthHelper().refreshToken(credentials);
+ reddit.authenticate(authData);
+ }
+ }
+
+ private byte[] generateRedditFriendlyPNGImage(BufferedImage image) throws IOException {
+ byte[] imageData;
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+ ImageIO.write(image, "PNG", baos);
+ imageData = baos.toByteArray();
+ }
+ if (imageData.length > 10000000) {
+ return generateRedditFriendlyPNGImage(
+ RenderUtils.scaleImage(image, image.getWidth() / 2, image.getHeight() / 2));
+ }
+ return imageData;
+ }
+
+ private Optional getMyReply(CommentNode comments) {
+ return comments.getChildren().stream().map(c -> c.getComment()).filter(c -> c.getAuthor().equals(myUserName))
+ .findAny();
+ }
+
+ private JSONObject getOrCreateCache() throws FileNotFoundException, IOException {
+ if (CACHE_FILE.exists()) {
+ try (FileInputStream fis = new FileInputStream(CACHE_FILE)) {
+ return Utils.readJsonFromStream(fis);
+ }
+ } else {
+ JSONObject cache = new JSONObject();
+ cache.put("lastProcessedSubmissionMillis", 0L);
+ cache.put("lastProcessedCommentMillis", 0L);
+ cache.put("lastProcessedMessageMillis", 0L);
+ return cache;
+ }
+ }
+
+ private Optional processContent(String content, String link, String subreddit, String author) {
+ if (!content.contains(configJson.getString("summon_keyword"))) {
+ return Optional.empty();
+ }
+
+ TaskReporting reporting = new TaskReporting();
+ reporting.setContext(content);
+ reporting.addLink(link);
+
+ try {
+ List blueprintStrings = BlueprintFinder.search(content, reporting);
+ List blueprints = blueprintStrings.stream().flatMap(s -> s.getBlueprints().stream())
+ .collect(Collectors.toList());
+
+ for (Blueprint blueprint : blueprints) {
+ try {
+ BufferedImage image = FBSR.renderBlueprint(blueprint, reporting);
+ reporting.addImage(WebUtils
+ .uploadToHostingService("blueprint.png", generateRedditFriendlyPNGImage(image)).toString());
+ } catch (Exception e) {
+ reporting.addException(e);
+ }
+ }
+ } catch (Exception e) {
+ reporting.addException(e);
+ }
+
+ List lines = new ArrayList<>();
+ List images = reporting.getImages();
+ if (images.size() > 1) {
+ int id = 1;
+ for (String url : images) {
+ lines.add("[Blueprint Image " + (id++) + "](" + url + ")");
+ }
+ } else if (!images.isEmpty()) {
+ lines.add("[Blueprint Image](" + images.get(0) + ")");
+ }
+
+ if (images.isEmpty()) {
+ lines.add(" I can't seem to find any blueprints...");
+ }
+ if (!reporting.getExceptions().isEmpty()) {
+ lines.add(
+ " There was a problem completing your request. I have contacted my programmer to fix it for you!");
+ }
+
+ ServiceFinder.findService(BlueprintBotDiscordService.class).ifPresent(
+ s -> s.sendReportToDemod("Reddit / " + subreddit + " / " + author, REDDIT_AUTHOR_URL, reporting));
+
+ return Optional.of(lines.stream().collect(Collectors.joining("\n\n")));
+ }
+
+ private boolean processNewComments(JSONObject cacheJson, String subreddit, long ageLimitMillis)
+ throws NetworkException, ApiException {
+ long lastProcessedMillis = cacheJson.getLong("lastProcessedCommentMillis");
+
+ CommentStream commentStream = new CommentStream(reddit, subreddit);
+ commentStream.setTimePeriod(TimePeriod.ALL);
+ commentStream.setSorting(Sorting.NEW);
+
+ int processedCount = 0;
+ long newestMillis = lastProcessedMillis;
+ List> pendingReplies = new LinkedList<>();
+ paginate: for (Listing listing : commentStream) {
+ for (Comment comment : listing) {
+ long createMillis = comment.getCreated().getTime();
+ if (createMillis <= lastProcessedMillis
+ || (System.currentTimeMillis() - createMillis > ageLimitMillis)) {
+ break paginate;
+ }
+ processedCount++;
+ newestMillis = Math.max(newestMillis, createMillis);
+
+ if (comment.getAuthor().equals(myUserName)) {
+ break paginate;
+ }
+
+ if (comment.isArchived()) {
+ continue;
+ }
+
+ Optional response = processContent(comment.getBody(), comment.getUrl(),
+ comment.getSubredditName(), comment.getAuthor());
+ if (response.isPresent()) {
+ pendingReplies.add(new Pair<>(comment, response.get()));
+ }
+ }
+ }
+ for (Pair pair : pendingReplies) {
+ System.out.println("IM TRYING TO REPLY TO A COMMENT!");
+ account.reply(pair.getKey(), pair.getValue());
+ }
+
+ if (processedCount > 0) {
+ System.out.println("Processed " + processedCount + " comment(s) from /r/" + subreddit);
+ cacheJson.put("lastProcessedCommentMillis", newestMillis);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private boolean processNewMessages(JSONObject cacheJson, long ageLimitMillis) {
+ // TODO Auto-generated method stub
+ return false;
+ }
+
+ private boolean processNewSubmissions(JSONObject cacheJson, String subreddit, long ageLimitMillis)
+ throws NetworkException, ApiException {
+ long lastProcessedMillis = cacheJson.getLong("lastProcessedSubmissionMillis");
+
+ SubredditPaginator paginator = new SubredditPaginator(reddit, subreddit);
+ paginator.setTimePeriod(TimePeriod.ALL);
+ paginator.setSorting(Sorting.NEW);
+
+ int processedCount = 0;
+ long newestMillis = lastProcessedMillis;
+ List> pendingReplies = new LinkedList<>();
+ paginate: for (Listing listing : paginator) {
+ for (Submission submission : listing) {
+ long createMillis = submission.getCreated().getTime();
+ if (createMillis <= lastProcessedMillis
+ || (System.currentTimeMillis() - createMillis > ageLimitMillis)) {
+ break paginate;
+ }
+ processedCount++;
+ newestMillis = Math.max(newestMillis, createMillis);
+
+ if (!submission.isSelfPost() || submission.isLocked() || submission.isArchived()) {
+ continue;
+ }
+
+ CommentNode comments = submission.getComments();
+ if (comments == null && submission.getCommentCount() > 0) {
+ submission = reddit.getSubmission(submission.getId());
+ comments = submission.getComments();
+ }
+ if (comments != null && getMyReply(comments).isPresent()) {
+ break paginate;
+ }
+
+ Optional response = processContent(submission.getSelftext(), submission.getUrl(),
+ submission.getSubredditName(), submission.getAuthor());
+ if (response.isPresent()) {
+ pendingReplies.add(new Pair<>(submission, response.get()));
+ }
+ }
+ }
+ for (Pair pair : pendingReplies) {
+ System.out.println("IM TRYING TO REPLY TO A SUBMISSION!");
+ account.reply(pair.getKey(), pair.getValue());
+ }
+
+ if (processedCount > 0) {
+ System.out.println("Processed " + processedCount + " submission(s) from /r/" + subreddit);
+ cacheJson.put("lastProcessedSubmissionMillis", newestMillis);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ protected void runOneIteration() throws Exception {
+ try {
+ String subreddit = configJson.getString("subreddit");
+ final long ageLimitMillis = configJson.getInt("age_limit_hours") * 60 * 60 * 1000;
+
+ JSONObject cacheJson = getOrCreateCache();
+ boolean cacheUpdated = false;
+
+ ensureValidAccessToken();
+ cacheUpdated |= processNewSubmissions(cacheJson, subreddit, ageLimitMillis);
+ cacheUpdated |= processNewComments(cacheJson, subreddit, ageLimitMillis);
+ cacheUpdated |= processNewMessages(cacheJson, ageLimitMillis);
+
+ if (cacheUpdated) {
+ saveCache(cacheJson);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void saveCache(JSONObject cacheJson) throws IOException {
+ try (FileWriter fw = new FileWriter(CACHE_FILE)) {
+ fw.write(cacheJson.toString(2));
+ }
+ }
+
+ @Override
+ protected Scheduler scheduler() {
+ return Scheduler.newFixedDelaySchedule(0, configJson.getInt("refresh_seconds"), TimeUnit.SECONDS);
+ }
+
+ @Override
+ protected void shutDown() throws Exception {
+ ServiceFinder.removeService(this);
+ reddit.getOAuthHelper().revokeAccessToken(credentials);
+ reddit.deauthenticate();
+ }
+
+ @Override
+ protected void startUp() {
+ try {
+ reddit = new RedditClient(UserAgent.of("server", "com.demod.fbsr", "0.0.1", "demodude4u"));
+ account = new AccountManager(reddit);
+
+ configJson = Config.get().getJSONObject("reddit");
+
+ JSONObject redditCredentialsJson = configJson.getJSONObject("credentials");
+ credentials = Credentials.script( //
+ redditCredentialsJson.getString("username"), //
+ redditCredentialsJson.getString("password"), //
+ redditCredentialsJson.getString("client_id"), //
+ redditCredentialsJson.getString("client_secret") //
+ );
+ authData = reddit.getOAuthHelper().easyAuth(credentials);
+ reddit.authenticate(authData);
+
+ myUserName = redditCredentialsJson.getString("username");
+
+ System.out.println("Reddit Authenticated!");
+
+ ServiceFinder.addService(this);
+ } catch (Exception e) {
+ e.printStackTrace();
+ Throwables.throwIfUnchecked(e);
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/ServiceFinder.java b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/ServiceFinder.java
new file mode 100644
index 0000000..0148e83
--- /dev/null
+++ b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/ServiceFinder.java
@@ -0,0 +1,22 @@
+package com.demod.fbsr.app;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class ServiceFinder {
+ private static final Map, Object> registry = new ConcurrentHashMap<>();
+
+ public static void addService(Object service) {
+ registry.put(service.getClass(), service);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Optional findService(Class clazz) {
+ return Optional.ofNullable((T) registry.get(clazz));
+ }
+
+ public static void removeService(Object service) {
+ registry.remove(service.getClass());
+ }
+}
diff --git a/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/StartAllServices.java b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/StartAllServices.java
new file mode 100644
index 0000000..9451705
--- /dev/null
+++ b/FactorioBlueprintStringRenderer/src/com/demod/fbsr/app/StartAllServices.java
@@ -0,0 +1,19 @@
+package com.demod.fbsr.app;
+
+import java.util.Arrays;
+
+import com.google.common.util.concurrent.Service;
+import com.google.common.util.concurrent.ServiceManager;
+
+public class StartAllServices {
+
+ public static void main(String[] args) {
+ ServiceManager manager = new ServiceManager(Arrays.asList(new Service[] { //
+ new BlueprintBotDiscordService(), //
+ new BlueprintBotRedditService(),//
+ }));
+
+ manager.startAsync();
+ }
+
+}