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(); + } + +}