From f9cae8416a8b18a1b429762d1f05cb23c5ce1c57 Mon Sep 17 00:00:00 2001 From: Lotto Date: Fri, 2 Mar 2018 23:26:57 +0100 Subject: [PATCH 1/5] http-api: add feed types --- .../net/runelite/http/api/feed/FeedItem.java | 42 +++++++++++++++++++ .../runelite/http/api/feed/FeedItemType.java | 32 ++++++++++++++ .../runelite/http/api/feed/FeedResult.java | 36 ++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 http-api/src/main/java/net/runelite/http/api/feed/FeedItem.java create mode 100644 http-api/src/main/java/net/runelite/http/api/feed/FeedItemType.java create mode 100644 http-api/src/main/java/net/runelite/http/api/feed/FeedResult.java diff --git a/http-api/src/main/java/net/runelite/http/api/feed/FeedItem.java b/http-api/src/main/java/net/runelite/http/api/feed/FeedItem.java new file mode 100644 index 0000000000..620f639d15 --- /dev/null +++ b/http-api/src/main/java/net/runelite/http/api/feed/FeedItem.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.api.feed; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; + +@Data +@RequiredArgsConstructor +@AllArgsConstructor +public class FeedItem +{ + private final FeedItemType type; + private String avatar; + private final String title; + private final String content; + private final String url; + private final long timestamp; +} diff --git a/http-api/src/main/java/net/runelite/http/api/feed/FeedItemType.java b/http-api/src/main/java/net/runelite/http/api/feed/FeedItemType.java new file mode 100644 index 0000000000..e3cc9443cd --- /dev/null +++ b/http-api/src/main/java/net/runelite/http/api/feed/FeedItemType.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.api.feed; + +public enum FeedItemType +{ + BLOG_POST, + TWEET, + OSRS_NEWS +} diff --git a/http-api/src/main/java/net/runelite/http/api/feed/FeedResult.java b/http-api/src/main/java/net/runelite/http/api/feed/FeedResult.java new file mode 100644 index 0000000000..cf862b5be0 --- /dev/null +++ b/http-api/src/main/java/net/runelite/http/api/feed/FeedResult.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.api.feed; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class FeedResult +{ + private List items; +} From 8259410178e2f1ba3633999db503a4943ce6ee71 Mon Sep 17 00:00:00 2001 From: Lotto Date: Fri, 2 Mar 2018 23:27:18 +0100 Subject: [PATCH 2/5] http-service: add services for getting blog posts, tweets and osrs news --- .../http/service/feed/blog/BlogService.java | 131 +++++++++++++ .../feed/osrsnews/OSRSNewsService.java | 131 +++++++++++++ .../twitter/TwitterOAuth2TokenResponse.java | 38 ++++ .../service/feed/twitter/TwitterService.java | 180 ++++++++++++++++++ .../twitter/TwitterStatusesResponseItem.java | 35 ++++ .../TwitterStatusesResponseItemUser.java | 38 ++++ .../src/test/resources/application.properties | 3 + 7 files changed, 556 insertions(+) create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/blog/BlogService.java create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/osrsnews/OSRSNewsService.java create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterOAuth2TokenResponse.java create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterService.java create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItem.java create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItemUser.java diff --git a/http-service/src/main/java/net/runelite/http/service/feed/blog/BlogService.java b/http-service/src/main/java/net/runelite/http/service/feed/blog/BlogService.java new file mode 100644 index 0000000000..11ab1fd9a7 --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/blog/BlogService.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.blog; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import lombok.extern.slf4j.Slf4j; +import net.runelite.http.api.RuneLiteAPI; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.api.feed.FeedItemType; +import net.runelite.http.service.util.exception.InternalServerErrorException; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +@Service +@Slf4j +public class BlogService +{ + private static final HttpUrl RSS_URL = HttpUrl.parse("https://runelite.net/atom.xml"); + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US); + + public List getBlogPosts() throws IOException + { + Request request = new Request.Builder() + .url(RSS_URL) + .build(); + + try (Response response = RuneLiteAPI.CLIENT.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + throw new IOException("Error getting blog posts: " + response.message()); + } + + try + { + InputStream in = response.body().byteStream(); + Document document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(in); + + Element documentElement = document.getDocumentElement(); + NodeList documentItems = documentElement.getElementsByTagName("entry"); + + List items = new ArrayList<>(); + + for (int i = 0; i < Math.min(documentItems.getLength(), 3); i++) + { + Node item = documentItems.item(i); + NodeList children = item.getChildNodes(); + + String title = null; + String summary = null; + String link = null; + long timestamp = -1; + + for (int j = 0; j < children.getLength(); j++) + { + Node childItem = children.item(j); + String nodeName = childItem.getNodeName(); + + switch (nodeName) + { + case "title": + title = childItem.getTextContent(); + break; + case "summary": + summary = childItem.getTextContent().replace("\n", "").trim(); + break; + case "link": + link = childItem.getAttributes().getNamedItem("href").getTextContent(); + break; + case "updated": + timestamp = DATE_FORMAT.parse(childItem.getTextContent()).getTime(); + break; + } + } + + if (title == null || summary == null || link == null || timestamp == -1) + { + throw new InternalServerErrorException("Failed to find title, summary, link and/or timestamp in the blog post feed"); + } + + items.add(new FeedItem(FeedItemType.BLOG_POST, title, summary, link, timestamp)); + } + + return items; + } + catch (ParserConfigurationException | SAXException | ParseException e) + { + throw new InternalServerErrorException("Failed to parse blog posts: " + e.getMessage()); + } + } + } +} diff --git a/http-service/src/main/java/net/runelite/http/service/feed/osrsnews/OSRSNewsService.java b/http-service/src/main/java/net/runelite/http/service/feed/osrsnews/OSRSNewsService.java new file mode 100644 index 0000000000..21298ab132 --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/osrsnews/OSRSNewsService.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.osrsnews; + +import java.io.IOException; +import java.io.InputStream; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import lombok.extern.slf4j.Slf4j; +import net.runelite.http.api.RuneLiteAPI; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.api.feed.FeedItemType; +import net.runelite.http.service.util.exception.InternalServerErrorException; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.stereotype.Service; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +@Service +@Slf4j +public class OSRSNewsService +{ + private static final HttpUrl RSS_URL = HttpUrl.parse("http://services.runescape.com/m=news/latest_news.rss?oldschool=true"); + private static final SimpleDateFormat PUB_DATE_FORMAT = new SimpleDateFormat("EEE, dd MMM yyyy '00:00:00 GMT'", Locale.US); + + public List getNews() throws IOException + { + Request request = new Request.Builder() + .url(RSS_URL) + .build(); + + try (Response response = RuneLiteAPI.CLIENT.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + throw new IOException("Error getting OSRS news: " + response.message()); + } + + try + { + InputStream in = response.body().byteStream(); + Document document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(in); + + Element documentElement = document.getDocumentElement(); + NodeList documentItems = documentElement.getElementsByTagName("item"); + + List items = new ArrayList<>(); + + for (int i = 0; i < documentItems.getLength(); i++) + { + Node item = documentItems.item(i); + NodeList children = item.getChildNodes(); + + String title = null; + String description = null; + String link = null; + long timestamp = -1; + + for (int j = 0; j < children.getLength(); j++) + { + Node childItem = children.item(j); + String nodeName = childItem.getNodeName(); + + switch (nodeName) + { + case "title": + title = childItem.getTextContent(); + break; + case "description": + description = childItem.getTextContent().replace("\n", "").trim(); + break; + case "link": + link = childItem.getTextContent(); + break; + case "pubDate": + timestamp = PUB_DATE_FORMAT.parse(childItem.getTextContent()).getTime(); + break; + } + } + + if (title == null || description == null || link == null || timestamp == -1) + { + throw new InternalServerErrorException("Failed to find title, description, link and/or timestamp in the OSRS RSS feed"); + } + + items.add(new FeedItem(FeedItemType.OSRS_NEWS, title, description, link, timestamp)); + } + + return items; + } + catch (ParserConfigurationException | SAXException | ParseException e) + { + throw new InternalServerErrorException("Failed to parse OSRS news: " + e.getMessage()); + } + } + } +} diff --git a/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterOAuth2TokenResponse.java b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterOAuth2TokenResponse.java new file mode 100644 index 0000000000..24df0b6cf6 --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterOAuth2TokenResponse.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.twitter; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +class TwitterOAuth2TokenResponse +{ + @SerializedName("token_type") + private String tokenType; + + @SerializedName("access_token") + private String token; +} diff --git a/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterService.java b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterService.java new file mode 100644 index 0000000000..74edfd0c2b --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterService.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.twitter; + +import com.google.gson.reflect.TypeToken; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import net.runelite.http.api.RuneLiteAPI; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.api.feed.FeedItemType; +import net.runelite.http.service.util.exception.InternalServerErrorException; +import okhttp3.FormBody; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class TwitterService +{ + private static final HttpUrl AUTH_URL = HttpUrl.parse("https://api.twitter.com/oauth2/token"); + private static final HttpUrl LIST_STATUSES_URL = HttpUrl.parse("https://api.twitter.com/1.1/lists/statuses.json"); + + private final String credentials; + private final String listId; + + private String token; + + @Autowired + public TwitterService( + @Value("${runelite.twitter.consumerkey}") String consumerKey, + @Value("${runelite.twitter.secretkey}") String consumerSecret, + @Value("${runelite.twitter.listid}") String listId + ) + { + this.credentials = consumerKey + ":" + consumerSecret; + this.listId = listId; + } + + public List getTweets() throws IOException + { + return getTweets(false); + } + + private List getTweets(boolean hasRetried) throws IOException + { + if (token == null) + { + updateToken(); + } + + HttpUrl url = LIST_STATUSES_URL.newBuilder() + .addQueryParameter("list_id", listId) + .addQueryParameter("count", "15") + .addQueryParameter("include_entities", "false") + .build(); + + Request request = new Request.Builder() + .url(url) + .header("Authorization", "Bearer " + token) + .build(); + + try (Response response = RuneLiteAPI.CLIENT.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + switch (HttpStatus.valueOf(response.code())) + { + case BAD_REQUEST: + case UNAUTHORIZED: + updateToken(); + if (!hasRetried) + { + return getTweets(true); + } + throw new InternalServerErrorException("Could not auth to Twitter after trying once: " + response.message()); + default: + throw new IOException("Error getting Twitter list: " + response.message()); + } + } + + InputStream in = response.body().byteStream(); + Type listType = new TypeToken>() + { + }.getType(); + List statusesResponse = RuneLiteAPI.GSON + .fromJson(new InputStreamReader(in), listType); + + List items = new ArrayList<>(); + + for (TwitterStatusesResponseItem i : statusesResponse) + { + items.add(new FeedItem(FeedItemType.TWEET, + i.getUser().getProfileImageUrl(), + i.getUser().getScreenName(), + i.getText().replace("\n\n", " ").replaceAll("\n", " "), + "https://twitter.com/statuses/" + i.getId(), + getTimestampFromSnowflake(i.getId()))); + } + + return items; + } + } + + private void updateToken() throws IOException + { + String encodedCredentials = Base64.getEncoder().encodeToString(credentials.getBytes()); + + Request request = new Request.Builder() + .url(AUTH_URL) + .header("Authorization", "Basic " + encodedCredentials) + .post(new FormBody.Builder().add("grant_type", "client_credentials").build()) + .build(); + + try (Response response = RuneLiteAPI.CLIENT.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + throw new IOException("Error authing to Twitter: " + response.message()); + } + + InputStream in = response.body().byteStream(); + TwitterOAuth2TokenResponse tokenResponse = RuneLiteAPI.GSON + .fromJson(new InputStreamReader(in), TwitterOAuth2TokenResponse.class); + + if (!tokenResponse.getTokenType().equals("bearer")) + { + throw new InternalServerErrorException("Returned token was not a bearer token"); + } + + if (tokenResponse.getToken() == null) + { + throw new InternalServerErrorException("Returned token was null"); + } + + token = tokenResponse.getToken(); + } + } + + /** + * Extracts the UTC timestamp from a Twitter snowflake as per + * https://github.com/client9/snowflake2time/blob/master/python/snowflake.py#L24 + */ + private long getTimestampFromSnowflake(long snowflake) + { + return (snowflake >> 22) + 1288834974657L; + } +} diff --git a/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItem.java b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItem.java new file mode 100644 index 0000000000..90b37c5021 --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItem.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.twitter; + +import lombok.Data; + +@Data +class TwitterStatusesResponseItem +{ + private long id; + private String text; + private TwitterStatusesResponseItemUser user; +} diff --git a/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItemUser.java b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItemUser.java new file mode 100644 index 0000000000..94fe9360f9 --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/twitter/TwitterStatusesResponseItemUser.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed.twitter; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +@Data +class TwitterStatusesResponseItemUser +{ + @SerializedName("screen_name") + private String screenName; + + @SerializedName("profile_image_url_https") + private String profileImageUrl; +} diff --git a/http-service/src/test/resources/application.properties b/http-service/src/test/resources/application.properties index 4d93d9bb41..62f152bff5 100644 --- a/http-service/src/test/resources/application.properties +++ b/http-service/src/test/resources/application.properties @@ -4,3 +4,6 @@ minio.endpoint=http://10.96.22.171:9000 minio.accesskey=AM54M27O4WZK65N6F8IP minio.secretkey=/PZCxzmsJzwCHYlogcymuprniGCaaLUOET2n6yMP minio.bucket=runelite +runelite.twitter.consumerkey=moo +runelite.twitter.secretkey=cow +runelite.twitter.listid=968949795153948673 From 3a78cf1e7b5aa6ba508bd5b731976b12c037ae93 Mon Sep 17 00:00:00 2001 From: Lotto Date: Fri, 2 Mar 2018 23:27:27 +0100 Subject: [PATCH 3/5] http-service: add feed api endpoint --- .../http/service/feed/FeedController.java | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 http-service/src/main/java/net/runelite/http/service/feed/FeedController.java diff --git a/http-service/src/main/java/net/runelite/http/service/feed/FeedController.java b/http-service/src/main/java/net/runelite/http/service/feed/FeedController.java new file mode 100644 index 0000000000..88e30c8eab --- /dev/null +++ b/http-service/src/main/java/net/runelite/http/service/feed/FeedController.java @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.service.feed; + +import com.google.common.base.Suppliers; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import net.runelite.http.api.feed.FeedResult; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.service.feed.blog.BlogService; +import net.runelite.http.service.feed.osrsnews.OSRSNewsService; +import net.runelite.http.service.feed.twitter.TwitterService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/feed") +public class FeedController +{ + private static final Logger logger = LoggerFactory.getLogger(FeedController.class); + + private BlogService blogService; + private TwitterService twitterService; + private OSRSNewsService osrsNewsService; + + private final Supplier feed = Suppliers.memoizeWithExpiration(() -> + { + List items = new ArrayList<>(); + + try + { + items.addAll(blogService.getBlogPosts()); + } + catch (IOException e) + { + logger.warn(null, e); + } + + try + { + items.addAll(twitterService.getTweets()); + } + catch (IOException e) + { + logger.warn(null, e); + } + + try + { + items.addAll(osrsNewsService.getNews()); + } + catch (IOException e) + { + logger.warn(null, e); + } + + return new FeedResult(items); + }, 10, TimeUnit.MINUTES); + + @Autowired + public FeedController(BlogService blogService, TwitterService twitterService, OSRSNewsService osrsNewsService) + { + this.blogService = blogService; + this.twitterService = twitterService; + this.osrsNewsService = osrsNewsService; + } + + @RequestMapping + public FeedResult getFeed() throws IOException + { + return feed.get(); + } +} From b1990c72c54da8624861b072a9c8cd8515254572 Mon Sep 17 00:00:00 2001 From: Lotto Date: Fri, 2 Mar 2018 23:27:45 +0100 Subject: [PATCH 4/5] http-api: add client for looking up feed --- .../runelite/http/api/feed/FeedClient.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 http-api/src/main/java/net/runelite/http/api/feed/FeedClient.java diff --git a/http-api/src/main/java/net/runelite/http/api/feed/FeedClient.java b/http-api/src/main/java/net/runelite/http/api/feed/FeedClient.java new file mode 100644 index 0000000000..a1f54cac82 --- /dev/null +++ b/http-api/src/main/java/net/runelite/http/api/feed/FeedClient.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.http.api.feed; + +import com.google.gson.JsonParseException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import net.runelite.http.api.RuneLiteAPI; +import okhttp3.HttpUrl; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FeedClient +{ + private static final Logger logger = LoggerFactory.getLogger(FeedClient.class); + + public FeedResult lookupFeed() throws IOException + { + HttpUrl url = RuneLiteAPI.getApiBase().newBuilder() + .addPathSegment("feed") + .build(); + + logger.debug("Built URI: {}", url); + + Request request = new Request.Builder() + .url(url) + .build(); + + try (Response response = RuneLiteAPI.CLIENT.newCall(request).execute()) + { + if (!response.isSuccessful()) + { + logger.debug("Error looking up feed: {}", response.message()); + return null; + } + + InputStream in = response.body().byteStream(); + return RuneLiteAPI.GSON.fromJson(new InputStreamReader(in), FeedResult.class); + } + catch (JsonParseException ex) + { + throw new IOException(ex); + } + } +} From 92e224fb944931d63dadf78ca034053900b9e4a3 Mon Sep 17 00:00:00 2001 From: Lotto Date: Fri, 2 Mar 2018 23:28:27 +0100 Subject: [PATCH 5/5] runelite-client: add news feed plugin --- .../client/plugins/feed/FeedConfig.java | 46 +++ .../client/plugins/feed/FeedPanel.java | 358 ++++++++++++++++++ .../client/plugins/feed/FeedPlugin.java | 131 +++++++ .../net/runelite/client/plugins/feed/icon.png | Bin 0 -> 15726 bytes .../net/runelite/client/plugins/feed/osrs.png | Bin 0 -> 20629 bytes .../runelite/client/plugins/feed/runelite.png | Bin 0 -> 19736 bytes 6 files changed, 535 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java create mode 100644 runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png create mode 100644 runelite-client/src/main/resources/net/runelite/client/plugins/feed/osrs.png create mode 100644 runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java new file mode 100644 index 0000000000..936a632a88 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedConfig.java @@ -0,0 +1,46 @@ +package net.runelite.client.plugins.feed; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; + +@ConfigGroup( + keyName = "feed", + name = "News Feed", + description = "Displays client and game-related news" +) +public interface FeedConfig extends Config +{ + @ConfigItem( + keyName = "includeBlogPosts", + name = "Include Blog Posts", + description = "Configures whether blog posts are displayed", + position = 0 + ) + default boolean includeBlogPosts() + { + return true; + } + + @ConfigItem( + keyName = "includeTweets", + name = "Include Tweets", + description = "Configures whether tweets are displayed", + position = 1 + ) + default boolean includeTweets() + { + return true; + } + + @ConfigItem( + keyName = "includeOsrsNews", + name = "Include OSRS News", + description = "Configures whether OSRS news are displayed", + position = 2 + ) + default boolean includeOsrsNews() + { + return true; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java new file mode 100644 index 0000000000..16472ec01d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPanel.java @@ -0,0 +1,358 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.feed; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Desktop; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.font.FontRenderContext; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.time.Instant; +import java.util.Comparator; +import java.util.function.Supplier; +import javax.imageio.ImageIO; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; +import javax.swing.border.EmptyBorder; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.http.api.RuneLiteAPI; +import net.runelite.http.api.feed.FeedItem; +import net.runelite.http.api.feed.FeedItemType; +import net.runelite.http.api.feed.FeedResult; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +@Slf4j +class FeedPanel extends PluginPanel +{ + private static BufferedImage RUNELITE_ICON; + private static BufferedImage OSRS_ICON; + + private static final Color TWEET_BACKGROUND = new Color(15, 15, 15); + private static final Color OSRS_NEWS_BACKGROUND = new Color(36, 30, 19); + private static final Color BLOG_POST_BACKGROUND = new Color(11, 30, 41); + + private static final int MAX_CONTENT_LINES = 3; + private static final int CONTENT_WIDTH = 148; + private static final int TIME_WIDTH = 20; + + private static final Comparator FEED_ITEM_COMPARATOR = (o1, o2) -> + { + if (o1.getType() != o2.getType()) + { + if (o1.getType() == FeedItemType.BLOG_POST) + { + return -1; + } + else if (o2.getType() == FeedItemType.BLOG_POST) + { + return 1; + } + } + + return -Long.compare(o1.getTimestamp(), o2.getTimestamp()); + }; + + static + { + try + { + RUNELITE_ICON = ImageIO.read(FeedPanel.class.getResourceAsStream("runelite.png")); + } + catch (IOException e) + { + log.warn("Client icon failed to load", e); + } + + try + { + OSRS_ICON = ImageIO.read(FeedPanel.class.getResourceAsStream("osrs.png")); + } + catch (IOException e) + { + log.warn("OSRS icon failed to load", e); + } + } + + private final FeedConfig config; + private final Supplier feedSupplier; + + FeedPanel(FeedConfig config, Supplier feedSupplier) + { + this.config = config; + this.feedSupplier = feedSupplier; + } + + void rebuildFeed() + { + FeedResult feed = feedSupplier.get(); + + if (feed == null) + { + return; + } + + SwingUtilities.invokeLater(() -> + { + removeAll(); + + feed.getItems() + .stream() + .filter(f -> f.getType() != FeedItemType.BLOG_POST || config.includeBlogPosts()) + .filter(f -> f.getType() != FeedItemType.TWEET || config.includeTweets()) + .filter(f -> f.getType() != FeedItemType.OSRS_NEWS || config.includeOsrsNews()) + .sorted(FEED_ITEM_COMPARATOR) + .forEach(this::addItemToPanel); + }); + } + + private void addItemToPanel(FeedItem item) + { + JPanel avatarAndRight = new JPanel(new BorderLayout()); + avatarAndRight.setPreferredSize(new Dimension(0, 56)); + + JLabel avatar = new JLabel(); + // width = 48+4 to compensate for the border + avatar.setPreferredSize(new Dimension(52, 48)); + avatar.setBorder(new EmptyBorder(0, 4, 0, 0)); + + switch (item.getType()) + { + case TWEET: + try + { + Request request = new Request.Builder() + .url(item.getAvatar()) + .build(); + + RuneLiteAPI.CLIENT.newCall(request).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + log.warn(null, e); + } + + @Override + public void onResponse(Call call, Response response) throws IOException + { + try (ResponseBody responseBody = response.body()) + { + if (!response.isSuccessful()) + { + log.warn("Failed to download image " + item.getAvatar()); + return; + } + + avatar.setIcon(new ImageIcon(ImageIO.read(responseBody.byteStream()))); + } + } + }); + } + catch (IllegalArgumentException | NullPointerException e) + { + log.warn(null, e); + } + avatarAndRight.setBackground(TWEET_BACKGROUND); + break; + case OSRS_NEWS: + if (OSRS_ICON != null) + { + avatar.setIcon(new ImageIcon(OSRS_ICON)); + } + avatarAndRight.setBackground(OSRS_NEWS_BACKGROUND); + break; + default: + if (RUNELITE_ICON != null) + { + avatar.setIcon(new ImageIcon(RUNELITE_ICON)); + } + avatarAndRight.setBackground(BLOG_POST_BACKGROUND); + break; + } + + JPanel upAndContent = new JPanel(); + upAndContent.setLayout(new BoxLayout(upAndContent, BoxLayout.Y_AXIS)); + upAndContent.setBorder(new EmptyBorder(4, 8, 4, 4)); + upAndContent.setBackground(null); + + JPanel titleAndTime = new JPanel(); + titleAndTime.setLayout(new BorderLayout()); + titleAndTime.setBackground(null); + + Color darkerForeground = UIManager.getColor("Label.foreground").darker(); + + JLabel titleLabel = new JLabel(item.getTitle()); + titleLabel.setFont(FontManager.getRunescapeSmallFont()); + titleLabel.setBackground(null); + titleLabel.setForeground(darkerForeground); + titleLabel.setPreferredSize(new Dimension(CONTENT_WIDTH - TIME_WIDTH, 0)); + + Duration duration = Duration.between(Instant.ofEpochMilli(item.getTimestamp()), Instant.now()); + JLabel timeLabel = new JLabel(durationToString(duration)); + timeLabel.setFont(FontManager.getRunescapeSmallFont()); + timeLabel.setForeground(darkerForeground); + + titleAndTime.add(titleLabel, BorderLayout.WEST); + titleAndTime.add(timeLabel, BorderLayout.EAST); + + JPanel content = new JPanel(new BorderLayout()); + content.setBackground(null); + + JLabel contentLabel = new JLabel(lineBreakText(item.getContent(), FontManager.getRunescapeSmallFont())); + contentLabel.setBorder(new EmptyBorder(2, 0, 0, 0)); + contentLabel.setFont(FontManager.getRunescapeSmallFont()); + contentLabel.setForeground(darkerForeground); + + content.add(contentLabel, BorderLayout.CENTER); + + upAndContent.add(titleAndTime); + upAndContent.add(content); + upAndContent.add(new Box.Filler(new Dimension(0, 0), + new Dimension(0, Short.MAX_VALUE), + new Dimension(0, Short.MAX_VALUE))); + + avatarAndRight.add(avatar, BorderLayout.WEST); + avatarAndRight.add(upAndContent, BorderLayout.CENTER); + + Color backgroundColor = avatarAndRight.getBackground(); + Color hoverColor = backgroundColor.brighter().brighter(); + Color pressedColor = hoverColor.brighter(); + + avatarAndRight.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + avatarAndRight.setBackground(hoverColor); + } + + @Override + public void mouseExited(MouseEvent e) + { + avatarAndRight.setBackground(backgroundColor); + } + + @Override + public void mousePressed(MouseEvent e) + { + avatarAndRight.setBackground(pressedColor); + } + + @Override + public void mouseReleased(MouseEvent e) + { + avatarAndRight.setBackground(hoverColor); + + Desktop desktop = Desktop.getDesktop(); + + if (!desktop.isSupported(Desktop.Action.BROWSE)) + { + log.info("Desktop browser is not supported"); + return; + } + + try + { + desktop.browse(new URI(item.getUrl())); + } + catch (IOException | URISyntaxException ex) + { + log.warn("Unable to open URL " + item.getUrl(), ex); + } + } + }); + + add(avatarAndRight); + } + + private String durationToString(Duration duration) + { + if (duration.getSeconds() >= 60 * 60 * 24) + { + return (int) (duration.getSeconds() / (60 * 60 * 24)) + "d"; + } + else if (duration.getSeconds() >= 60 * 60) + { + return (int) (duration.getSeconds() / (60 * 60)) + "h"; + } + return (int) (duration.getSeconds() / 60) + "m"; + } + + private String lineBreakText(String text, Font font) + { + StringBuilder newText = new StringBuilder(""); + + FontRenderContext fontRenderContext = new FontRenderContext(font.getTransform(), + true, true); + + int lines = 0; + int pos = 0; + String[] words = text.split(" "); + String line = ""; + + while (lines < MAX_CONTENT_LINES && pos < words.length) + { + String newLine = pos > 0 ? line + " " + words[pos] : words[pos]; + double width = font.getStringBounds(newLine, fontRenderContext).getWidth(); + + if (width >= CONTENT_WIDTH) + { + newText.append(line); + newText.append("
"); + line = ""; + lines++; + } + else + { + line = newLine; + pos++; + } + } + + newText.append(line); + newText.append(""); + + return newText.toString(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java new file mode 100644 index 0000000000..714ba106cd --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/feed/FeedPlugin.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018, Lotto + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.client.plugins.feed; + +import com.google.common.base.Suppliers; +import com.google.common.eventbus.Subscribe; +import com.google.inject.Provides; +import java.io.IOException; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import javax.imageio.ImageIO; +import javax.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.events.ConfigChanged; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.task.Schedule; +import net.runelite.client.ui.ClientUI; +import net.runelite.client.ui.NavigationButton; +import net.runelite.http.api.feed.FeedClient; +import net.runelite.http.api.feed.FeedResult; + +@PluginDescriptor( + name = "News Feed", + loadWhenOutdated = true +) +@Slf4j +public class FeedPlugin extends Plugin +{ + @Inject + private ClientUI ui; + + @Inject + private FeedConfig config; + + @Inject + private ScheduledExecutorService executorService; + + private FeedPanel feedPanel; + private NavigationButton navButton; + + private FeedClient feedClient = new FeedClient(); + private Supplier feedSupplier = Suppliers.memoizeWithExpiration(() -> + { + try + { + return feedClient.lookupFeed(); + } + catch (IOException e) + { + log.warn(null, e); + } + return null; + }, 10, TimeUnit.MINUTES); + + @Override + protected void startUp() throws Exception + { + feedPanel = new FeedPanel(config, feedSupplier); + + navButton = new NavigationButton( + "News Feed", + ImageIO.read(getClass().getResourceAsStream("icon.png")), + () -> feedPanel); + + ui.getPluginToolbar().addNavigation(navButton); + + executorService.submit(this::updateFeed); + } + + @Override + protected void shutDown() throws Exception + { + ui.getPluginToolbar().removeNavigation(navButton); + } + + private void updateFeed() + { + feedPanel.rebuildFeed(); + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (event.getGroup().equals("feed")) + { + executorService.submit(this::updateFeed); + } + } + + @Schedule( + period = 10, + unit = ChronoUnit.MINUTES, + asynchronous = true + ) + private void updateFeedTask() + { + updateFeed(); + } + + @Provides + FeedConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(FeedConfig.class); + } +} diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..95b048b153ed765e96e4a89a07eaef0313b01f46 GIT binary patch literal 15726 zcmeI3du$X%9LHCw2HJv+P({VK9?^ihx3_nDk6o?@rH8b|E48g~g(&Rp&h<9E+dXc# z+!ZxezzEeuL(w1*;sZh?v6^aPe8&(3CH&3T^LlpgF#MX4ZtXQFs&2OR zzJS`jcP>RO+NJhH&1mEnQPxvVNzr4#nMo&ZQ-}ADDCu^r>2t~(QFM!Mhf|K(K0XKgW?RIfK)+MkU&$u|zC5SGLF1{S0 zTKFrNQQ~4(_~znpFlurnOfxOAY&M&9W<5?l)5mgxAh0es>vl7+2V)Fsrj%neV}8M; zkNst1XX+RxURhLfarfwwkDTXYkryLm0NhDGpyXx_#QdNgG%w-!Pf+EtYN~32m z4cIPV=mQxUEZYcAWq!4@H`RD$awa%gnJJGQw1hEg2x0dZtI$ zlcB;Y>ntIpTUxAD<);^{Ry9S>8ViFgDyhtO#SMfd6NI2Lz_>h&%iY6yL^oWI{u`}@ z>9Uy=dLkYXT~XtMRXljN$ouRbww|a5+&_wBN@wL_?`!i>WHGL1Qj!@`Q&Jyb(^_AU ztu)$tmRFV7q9^qXJS-6M1lelMHdTJ5iEWx;N}3GX!Xen;R8<8c;(T7&CooGRSmd42W{&XuHJkt>joPpww4k} zHD8m`QpNz*j%#w1wp_W0m1~rx$?sfIlC5eMlC2E^lps5ywQ^WxWl^S1p1`xCT=2Ka zrye7l02*#X6|C~TrZzG3xS5qQpt%pOiL=@31=oo()IP{s)jz5J0~29L8_#VTcuy=K zDF845zei!*0PdqeAm(Oxi35JGz{dmuF7|Vq2Hqulg;GuW_okt;isgi)^#LWsmd>oy z@L!s_v#WsTICJ)^$^T&H3OjLPO)Qo2$u%@+-AIVA{=!-y_B1N(#Y^pJR9aEqrm1GgT^tQ*wyCVUox-KK8{U|Mkk9M&c!Mn3 zj4C?S97smPD!gVI)|G(s6*Xw0s<7843ju-;2`)SzBuj81K=2{Kh3A802`&T(J|wvC ze2^@`g#f{a1Q(tUk|nqhAo!5r!t+701Q!AX9}--6K1i0}LV(~yf(y?F$r4-$5PV2* z;rSq0f(rqH4+$2gwp#2oQWoaN+qNS%M1zf)5EUJRc-Wa3MhO zA;E>`gJcOV1PDIF;;OAYZVNQ{jBOS^O#7pAI0_%crRCPH2t{qWj-rOPP}DDH;P;ml zHQ=JCuhvtPxQn7L*LSQtax+C;(AgGl?#X@l;K-ZphnLLVI=*sDZLiz@*%Q+rx#QK} zs5|c3vhVhWyDxg^r+eo-&=Y(*HGkFG{@VL4UAy#}WzU^FFr#hcP~G9BV?TU-%UB&n z?XNw?(U%XUTV~NSm%KBy{`kYE+7Gn9%YX1qD1F6=*)NSQfA4fG`ov@VUL4^XzdU{9 zptNgn+sU10_@h_8HuL+oons>x4b3^R?s($$;mfZ5Ff zZliI}RYTE*+o*<)&2-=Bdq2;Z9(ni4ZOuy(y~8KsBWq68QyY@ctc>0u)NCE^J6QkL z;d*{= zj{!fPLhzqtUG<@C*h&F sL-Tqc{o>a@f4`*b@h#r^#nY(m%JONS@Za8VJrUiutRpQw1P}ia%19t+N!*3z&Bw3fMYgvIvOG(yi!Wse^4)Sm-q>6_lT-QTW z&%(pTLd=p?T8c!%O&lBGHrxpYbh~Y9hZc8}Wc?mj9BUsogIR&!B~CVytg?p#0u42^ zfb#Z^aG)^%B|ZxXR1_#8#t#t|6BQKV1qup4E`bHazz`um0f@MOn79B0`18doMS`^? z94!&zItofZhr|9Q$!g`~T7coU@Y`@ZCp6X%@{fKFR`yQzXe;}F8S+>6zYdI@6Ag`D zWB=p%-oE{hL!+G(U9bp#BK=2Cw4S>I9IOLJ+dDg2z!hDvm%{c>SMP*G{53g$i*(rY zujdYTL;j1~Vb2e@@2T)(h9t1pDlYE`hdJ3h>e<`dO8v+(tv?Y0<>e2vN(9Jt4QXd- z?~3M@0RQgt*L?pK4Xyxlf=gjNMfn7v*ksg$h=~h9#RUW}9ww#$PLLnU-(qOkTOtwe z{~SY56gLKr^7k0n`muyL!Tv{4aJ4vbEG@(l_Kvq7jgWW9&@L!EM%3oO} zE^lvZ?}$AuxD-?Z{7=m|Uf(lKT-6Tk1hcb%t13ug8~Bk(OK~XF5+(|?8@2!7!*do8LNe(baH2kpQBw7C~ zxnGOeuQlq>=X>Rf!z>P0GbxM1f`D5}fdAI|*J=Io{ON}L|DC`e@p0V$eGFGCxZQs% zs2`!etNuL*+8*KL3Uh?Z-ooa@|D@J`ME$$l@8KoDhpWFW^6wMzqxJ9e^Jm8X=X^>W zE^6Y~T)>_e?E4%*B*6dC_SeWi^oECXYU})6FCr*_eSsl|pFdr3yMK2z`tPo|-M_p3 z<*9FnbdnPMIofZ{I9`V4BiV(S-un3Y1J zKYf1h_(w;he{;m?`04m#s&Kx}zt7~4R_ta0yH5fC+Nk_}l7AJ#|FQAc75yIv#tnBo z2ri8y?PFX=BEl6MV{5)rQ87#A*$ zBkf~cMqtbnf@55`G>){7aUF>WS8$9Am&TFyF|H#K;R=p%;nFzL zKE`zrEMR3Z?c4u zHlwQeBey2EFlTA5^3GD_bdu-Nab!EkF?bWa=>~3(ysxUMCT0_}6c79nBS-AB#ROPp96MbTo!oVX zI7r!?my4y&Gc!iio;xrU*wdJkKoi?9d4C}IZWc9h5cxL#)4ueRsJa#RRck4tOoLJq|MY3wV{yvkkmjcPmXU@usU|oj1Q0?w1I{Za0~0G zWRerJytY-!B#?dL@S0uQXjFG2B~<4Ghq|`0?|D(q?sSgssBAi&$+0@mm?*bbPGN6e z5O-eor?x5}E|)v4m7y(VZXQm2lD||)=u-#^X);zP=PBz^$ysAZ)7YGGOZDuYZoXgvh#a`U0)o+|k%Ktc zN(uQuO+W==hp}k!Cxh=5wD(7O+wP6z>8RCgsiX@u-%M`#_|_PcMQGF7a*dMxdJBKh z)=LhaJkPuj7YE$&@e0_rsaX`3+yUI@LcQ?JWa5ky7iq*viOZDS#Fa$x(pAFi#Fbe{ zWTQMsP4YJ^IU0;3w@8*Rt8>Z_hbZ4nZu8bP^j|SN4eVBGm`qeC0*`C2RpjgdKp56lFr3Wl{cD3Ztm*Q3;)hG|)oXy0?D$zi7wIW=$(!&)RTfXO7}6J{d%nisN=#2FRJMY1x+5-UR>E&@01aj_;A6Wo<6U{(sbc+S!?5zmAzw5T4}G87ZVNSf^Gixo<`74>+vxTpn;ns;}3JElm+jQmCX(=+vWe4*4Cr>3)s%av^c z1`@fnXs1;$F|i(@dZ8sYF2(TqGcq!F;3v2x3#Lfot)~-Sm=f4?)70lZO2ItUU6nvF zSV;P-4K@y(nan-^6m!83ZJ(V4)Ht)DM6dihyM%t0*{~x!oW2s);zGDXG_%CtqfZi2 z4`B3f2low?PScrr@8L&NJ+731hETgZla#ZnT3|>o*^bq9nk-L^lD&*y>*nF$v@y-? zpP5~(v!uILcm;y`D6GlgnbHOoR%`NV(A7yVQ~0nl7EPy|>6b`ra-%R%R_la70!wKe zs8q-VviMGGs5n_TmM^T332ti}R;Uxg75W^}ru4ME+p3I?Va9x8jwOQ0(@(N%0pYcK zhJ(LG?GF3_SI@(~dnE2FFPJCEnD&L_Cj}Q>q9{$r}5%t{lcVCzBBdx6UY&eh7P}GP4nq*-gWi645OnI zYO0xf?qZwdJo;3R2F@7BbMxTIn)5b$%ynGqsQ9?UxK)hmEjNW$Bsf;K$6iEJpQdPN z$eUf9XD5;DFkrM`hZ&|BhPmy`N+wBrC+5u2HMfkX6?yvy9rQD=cgv^VwC&}RQ`byQ zhIx5m5E-3c+DTDKJ%hb|4Q!d9Qo=_~D)A0$pS4rm7Kk2}sg~%?l<_m&VoVdWcnmzU|PV~poEIIh27TX;TeHjrQ7@U8Ij%;GG}!-Ir{K;91?n#J$&t!ifRVvjYQ z@ghzCeSMeZyEN>i%4em+wFvgx;twt|Z?6zNF-t<&rAJU5rc=-f(ei^Q)siK)klVT9N; zNlhx2EGOQ%T(TQ^Eo@S8^C@YhOt9Aqka#@o>>mPYMMaNvADc~de`FK7h7fgf5MjPE zGrsa-`R@J6$(WNt5v9+|u4K^l+6v9g&%)f{34L-ow7?ZN(B9t3%)>I5=x8QEm3D5g z4i2sNMG6?*UoxjC0I`nm*_fw2(^J$6sjq?BRt$@3-~1NgNk=#O>WqD5N_k#TLSjnx zsHV}gyC+9SuT$S=FEi+yZv!n}l2hrt{`}SF&!F?2FY0rmmA8in4@A6oI8)Pxo-%mM zl9ms%Pff=^e3Z%=%32jlkQmOvO6>5NFW3MhgwE#$n`@{+c)D%**Cv?nJh~q}Ju}Ng zZWR!N#|_sY-E`KEHHeitHH?ybLDwrH@aBxjXv<7uxoN386$ex$XJ&D+Nz}01+JC8# zGC)oxM-Qk;S=ipGn_ShHHH;5lI5@ZAk<6Lm7RqOG$?;3k^r$gH z(7s2v2lOgX6CGQ{$X-H&B4fq8Q4S5AKe*CH?I;N;V6@tEbBNw4$Q$_v(|ohf@7`tz z&7H1svmztzD7srt&rovzF8iczOMHy5(CZ+x1}AevdE|8VQ- zt}lXqbJ;s&IEx20!rDgaHwmk%R%Z~~<@Izu@Sn|5-5;NhsAjSpOkQqAXjFt3e9U>A z`gvOV;M8bVlt5oH4R+CES}0UwxrmB;Yu%zkQM^%=yihZ#8AtQjeP^}>S#G?0kkX$w zxHjA6l_FHiTf|#Dos;TI?6x+2^#RBio3A}q`Q+zL3nlUm7sQ0DPDpHj-7-w7T{D@u zJyzzkb>{;cep5LwAHOdD7Ub?Ge&pU~`%^KG4SC7&FVy6PtjW0c)0&`|K8bShIF-&v zxI&-i%CTT-R)^IYq`Uz4u-`lA>FZmp5+G!AU!P*c{tj@!SZe3(Rn%hn`$|FSF0^N= zCALUz?9Pc@IPsLR=p?Sn6m?MM+4DLbgt$v<32%6rKMD?#ME%8fz8|o@q-~1*fBGoo|(K*K8%hpD> zwJ`Y@WkAHi_IT}^cBk-^CA=k47!8{`uP>YT26ilrxKnj4 z?GYTT_$>n*8qQpHpRSgAodA6->hXmj`?2-4@UN~1`|4?g4-*O<=RV6u?Cl7QyMNYR zda<>*yOuMZkeb?X;VP!WeWB&x{%R@b32aS%>^fLHU%BkFwZ21tOYF`L9#d=X16Wm+ zdRd}I=E|21tCu^mUFg+M?mHg$6^z-^I1J8g7`(_b&(9jO)tO_Q>Z~4X6CSjkOyn~W zEk3v=v6G<;&{CTw+V$SlCcDZbI z6Gi*JtsyAFZ&h#AKGd{DzZ2t0C@CAhT~*aKvh^y+ZGLnMA|zA~PT6IVzWe3DxWEqI zd)MOm&1zcu_iviREkGeR5j%qCIXE6BKbqXFoJbN!`A6PefJ*EJc{fA57;omfQ1}3e z*>ca{usA4{PVcuS52Rqr8z{Zg!Z_lz&L0?PFp*XN4nO>+JL*A5>jj$u($#m7wG9c- zB1re3A~n7{CD|D#{elf_ZWXvZdeYr;hs-P3bJ_jggSXl#Ux&!HXEVIl>32G+Ht3(P z7d}Ic?=8dD-<9)%2UR~2_Uh`^IPRX)ti2l+A1T?jN$;{2KmL`eKj&hSxHH~7Z<9G# zTN}OfrD$lcO*tX8npB2Xi8St739{FD#G~ah9g~qpkFDV4%kmhpim-x7&+$FNiD}K= zLETL9K$W2(qOl~I(tf!)$}Q;?*w}W2E5$k16wgiW6t~@F70koBIzeP&x0MipD_+@c z%a8f<{*F9?_rKP2Y^Cg$sTvuF@ z&u{L9$p@vdYhd!uR!#4dq2>#gL3Ynp>Zz14ayPfDYxiiNP~jaUDF{N66p%zkw)r-8w1KVEct$OXH2x~-cS)uQN8wpu*Ppo!iBHfzk zECQ9T?Ms-Uykr7fhDx)A%sa)BB(GQGhP-uj6%O?c-AF-P&UkC3y}&fJwco_N)+)Vo zGJ;96(|i9DbE$x|MoC}OVC^R3K>mc^?~V@)roH=%bo z_01&RgGJ1wo0~fZOYIsiDGl11cgz^)Mn4u?eIwhMBDrA_SY#~R{!MztbHwp|wHVqi z&?T>KWCV_R?$c)Qu69`!^IX>T+Nmsjo#4cG&s~ah@nb=)uWQnV>?~|wUEkX0Pu&|# z%_`~n{C<3&F#c92R8X)!NP3N%qzN`$z=2AnXo0+tJdvk72xF|-+?m${YYOULZhO#h zl|}#cfQ_pTlUbXI%Shc#_h6+vAsIobpZck~dV71@W@e1-p065I*pB*Sz208Epr)3O z=H=t-HcYT^K_tbSjrbX)=#)gM^KDCY>&CLi>nHh%wpvx3F)6CP5BB=lX6m|#ILjT`%{F9CkmuS5>Kz#uA-sh%%_w zz|i3pBs{9EUt6PetDEh$FRrO+EH@ExSeTJIyD;9dq7v}&f%7)E{kpRXdoYQjLrT`R zQ4;a%{EU9I;a0(V(zg+`B*c>+yxOQ$Nh(( z@zlGwd%HVXCl$1PY>LL4vP(>8nT!CBug2_co+YiA;AzP9&Ap!b^R zhfwS9u^vvb9^7P+YhFvT@f67lHPIuy6*(AbcZ1e2S4$%WqwJcWc zoOmImZwJ@8`%i#!>Rq4ed8wi&YP=SJ@zxVoNN#Gm7(Hq~01=m}hHXCSXDXQwNp;Sa z)uOG2rbw2CyDp=vE@K;=TMk{L&r0v^aZ&XXWM}jDS{D|V)zsIUpNyvK5t>?S$5%eP z4F9y7r}B}TMlSOnNCRbs-S9#T&E#nVNh8Cs)uc`vOI+57xE&War8=z9nIxuI;NsK# z=3Go!OsICDmwNd6%@>dJ$om?n-n`OxHpbp6^L4>Y7Kvtzg;C_bu5y0=e0J)3SXfxk zXm616+rdGNYu9{EQwbY)aFox9Y1TMK4JD}lp+$**p}?_f_8v$T*%Aok?HQDoJNRJj z|M~=gXGznkqtwDy3SauqkA_3PdrMyZxNRK~^*f6F&%Yg;I z`JL&@D`%QLY&(@#f<++AV`4GB0n{K>YtEmiQ>b^!14%QVd5O(db}~u9wAv`vM~xPMOy2X6d*B|!inIdOFUVQPm#9D z4^}LjT?B0Q&2rBMI#vo5#mKn4W00f7KcV*7vASXE+pA&yQT@y({yJ1V0eah%>vGjD?x0hYub#`@10FL`5va&dAc5` zT&gqW#O_*C?kQ{q|a^xD3l?>5^2~uR7k5*YC9W3oZ&oqP4)J^Ri zuNsglq?rnzWo){&Fs3T;vyTDSWR%(;u^g&TvG-mt7?zD~oM!2!T!|Ax(}Ic1KqSJQ zLlGRQe#^T`At%AITsl-#Ep+KRF`)z?Mg<=B0-@F-YT&9=pgG<#LYw{4+R9ZVSge3s48kI6w2N7{eS!>p<4g| literal 0 HcmV?d00001 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png b/runelite-client/src/main/resources/net/runelite/client/plugins/feed/runelite.png new file mode 100644 index 0000000000000000000000000000000000000000..a6c7fdcbae094d4be57ae24f707d238c2798984a GIT binary patch literal 19736 zcmeI4c{G&m-@r$yJW7U0X)(sWj9H9fCfkf5%jiLNg;^Qf$V?$wQfi1wA)!PnyL#kl zkyMBzQkD=cLXnguYk5anH1*8+o!|SM^Pcydxz8Ch-|M?w_vd?E-}}D*xJ6rS*(@R~ zEerqvM9fU_Hk@zR+(&2;=kF7DzlWT!Km1Ldm;k`?RdXK!KuX#g06_ON-PV!iXi313 z8NM1s3WG$|2=evkSaWm+LHdf=hbaxjQa(E~eZS;8#+ji{b<)7=5o zt-H6_l6QNP(G;+OzOZf(h7-V-$|8b-e0}_wm>@mym$(?t@3~9m+1TP^}KxjzbEozer3m@2n{0oL*W`Q=aOpQW@QhlhtR6iDz;|Kq(pT8%A#bA0e{vqXe^*<)&)Wp*Ad+y)P*Vp&A zshKR}T^s~ok^a_`X&dZMh1yV=jGX~ws_`z)smT5A>{)c$kK+6kX-@NxbEgK;|G{le zGw=3gDa=<$mvgQdqW~(A#R#xvFnsjqSDDo}gdiiMxmBeNQg)#GQ5bwB89}1C0{Ewn6v|SK~LdMV-0lq|*KHZn-L52GJdFVoa3@)(zzN#=r z3?D`SXIfN!O}kL+{aUq*k9yKvULW;M5HEG;o+eoPjTGifutK4(CKPN!f{WGyWU z5~T&9z=&E9m=2N%K_d`a5N#Mti%g}^$Rs4?%S!s${m0C{hW?hF6MDY8QW#`T_#d^Q zp-3oAEfgApCUbJ2;7AmNM1j#DWCV&zhLMO=DvkOr(Sl&VB>FRe&T0Ncp9MB^=R)Bm zBhpAZFdc0wgb3HvglH3ya0r@4B11GOBsdX;)+7^=+TT+ACd@CXOzCsg4dyDtTy3^e zcl>wi}P9k?bMrirFOG~px~1W88HAS5D%41uE&Fl`cvq)8=yuke3Y z0UeAc{9B9vKfyl-dOk7Plj!F`rRYPyt=Vq_|DP)MpS8gG6aFtM_UkqE-zggREyH|N z-W%s%tK9q!{J9N&YpuVwP2IWMDGaCOa<)J5ORM8tZ+{v4v1aD&9p_r7&(1IQ+6Wlu zfWqhgesx`_=5}@B?YdCS?fS#h&X3O0M|{o3HN3!U?w)V{hGZ+}@r0_6($dn@(uHyl zb9-3PgQz}^csl1sf;rdxkvd;}xD~&3bowX91&XhZ^QBtwAo1nOH9yLEOyN8sLBBsD z{c=(Je#!YafBZP3e={mqEPfI$AiPCBF5Y~&w0vA#KzNIMT)g>kY5BOgfbbUixOns7 z((-X}0pTt3aq;HErRC$|0>WG5d|bTwaB2CtxPb5$`M7xV;nMPPaRK2i z@^SIz!=>fp;sU~3$Jz$9vz)wRx3}7TZfOrm4|x~Tbz0}}hK?N@{~if=bTsMwZA+UJ<%IJE zK>Q+M$P5-VwOD@QV3V7jg0O18k#3W)K<2q@Av+bzv-0x3HcDGowH8%w@j0TmF+{if zTHw2ZwBm8+nJ0a&slvV0%}=YHLQaiMv|PDqq}{c{&@LJnnXM`&00Ps*8$q}KBGQ0> z${~Ou7LR|gKLKQ$+DUq`1(X3GXSs-ErH>+-nrentK%w>F7L|A*aDHyCYUY^_#B=Bf z!%9%ZnOs3wvDRIf8q(A%3Vwi%7X<8;0NUA=(Ufk+*xXnQwtBNytHrs3AT0O?AoXzEkSET98d-q>>RsIcLlp8bHm~4#j=8cy7k+o1Xlf_77dIS z6~}>YEzZ3dAr8BC!BgA$MiM9@B2)P#u`vWZEQ=R?*)e?gZVP)d+^JM!1`v~3BcLcQ zMp87~V`aAvN8et=w7}aoA9PPXE^ployz{CHWrO$a-z8(6=?Db|a9yfT_Camg8PmmzJdWOgLb zQDO zqFJsDC)qTDq;Wv8IHH4O6wy#N_c$^NXnVWv?i$5MPgK}d8`L80kQj*ynxuO#5RZRy zk!auCsGOR9s_}L+*&sA7df)iu6S8-KI36$0W~-9I5dvotE)QS{_`NCzus39u9Wk{l z8_zUv&9@#k!2>eePK79S!s#c#ueaq*JqMSE5(h^w))K5F#c%hd7FdAoU_o^>{2Z1W+4ak(n zhrW+4rl-U}aqpv!^x!i4d)mij_k+X*BqEl?Mc(W*Ui9HPPKE7vVb5ygqgJhZ#GEXL zfj8J&>TRkjQ)Eh)E3d`L63NA)Lodz^Sy2+-(XKp(3z;W9HpkQxr<_5 zGh8hHhK?zPPVI&UvsCkP8}7Zrt(B8nlC=!A-)Y#RioRd1GFw?qKzPX+WGR!OzC>~i zJhaKbp>ulZ^$}r8>U8hZs=PAE^m;GF#+Egx2Gy7DZcZ*tRx2K7_rXJngFMHIGL z)Lj@a6|D~*_|({ZarNu?)CVWks!oLEUNEV@w6qy>_CW?HRn26#h>x+D{2CItQXG}o zLa1w}6_tC87ed2dj1#?GZWRT8EvIkLmf)3Q&Uo=;{Mc2?3q;3|NN?N_U)gi zgWf*hK0WU0R6fWwyz8|>C^?!f-@AeW(OkRrR7!P_7rQq4_|QzV!|c10GP>gvP=j{AE3TnEJ+m{P zMaxG^7&mr~PINHRy_&pb_PpO)6%{r8?%F{2pJ6OAxx1u-5iyZi+%|Gy_qZ0O!!Nrh z&)6mp>CAe2=XsTaj>X1pdq02J^U+byApV27`yy~*bV7k|vQU=Q*3{E7avR5|_jOlq z>2*4t^+rkmW2L9gMB{yE!A?{|nj1Q>_i1f=dq(TZqQL8->#tTXJLz|2<~bwg<(P=IpqR`e)i;gD8PM&HiXRS&%3yYlzLsllT$~Y@ zBQ@od1A4G8)jPOwh4v@=?q#qGH+E(%w}(7mvtMVr(jqPAq4*+DZhk~ewkJ@QHn85s zf?#$mU)^n+v$J%p$p>>VqKbJewQv38KHZq2OADv9m{8z5dZR zYMQGeYLEn<-HXIjY$Idos!Tz_Rc{gv_vY6aYY9SC2|gk&8ka1V?duqS+h{fOzRg1$ zh3V5?Iy9Aa$MloX*=-?4g$~7u8mG{+6Lp@Y%RQTUFp0I)9x-KlsJz$OHU?bf7~4^e{XKR9)jNVfA!?U{mzW>j5n`e3WWzf zDhtyf--}kxXT7Ln_=%+Fi!zG_YK41hE=A{4!5V!blw0iBD8NPh`OUWWg)zCbU{{IL zC)dR}m)XZXy-?n0_V^VueR^NY+3?S;3~1393bhQBEvy;#$Sf?V`Sx!0BE`z5`EF}# zrXRd_NVz7y*McPHyemmHnN9##0@p@LschI9m7O5_*T+|H0v%e5oV{b=^$Nx>{+JkL z=zW4do0z&i{%rQ;@`i$Z-~3(6>ZJktf)<8H(??h2+Nt7%y6hi&l?}gtAW3gsx+ETF zx6c%47wsaJ+;&kGgg<7s!$CkNDN*(AQ|%oZHgBIBr6DIhS?@DMXZkm9+%T#=K3Liv zFB<9|cD{K~lpMSoJD?Rtaue}l71BKNK8@Y}D<|y4dDj~;m}O_NDuuFH4;>Aoq$CBb zE7nF*b_|@EP%s2ltnWyT&gd_L#^53^z5w69Inh%DsoOHxe(f=))eChfbK;7J>+6q8 z3rs>^k0q@;HXY(U>0)tju=K%U`ju@nJq^R*y52T&9jn@GT}Aqpw*)C~!g4DveZD2X_5zGlVAn#z*<2k%VRoH^z-R@2z+HZf3+-Zg5fJZzE|+ORz&ea|bY z)HvsGm_%d8g{w|4ZpC7k%9g^)kVq^&_4uKe7=Kj2Mq_npAQ}wy#0+CM3)EkPAJ`Xz z`y)OkX|^&s(ZR$A6p&cE+(3V3a5kv-&ZL6fDVHb1FAhGN9o;I^V_vdhDxE$W_|Ee} z$N74Vn&?%pkGJ0 zeMm}X4_1(zsd0-yiHmj=W2a9y60_fx?rg2!UfO%bXR_w(vP0$r!^O|qWbCO{XTuaX z9NCp(J6WtP^@({l!Efssy80{m*XFswqG(=luALoFnnyFKWG`~pT%$+@?ZVXA7Cor z-3DMAorrD*-YRh}aJGzAwp1(e%8f8>+Gu6@RC(NBQ%OQ_qpQadWYg-8U8Q4P%T9FI z-I#n(a7tiFgDYB1V|=XiRP8nQp=&C^Zp~1)jgqd-o^}lkd&rjC>?=SYmQjE_U*%oeBqhUXIyOU-ITkJctB^l)=QNq?H23G{KBuY z=!2uf{m{;t_P^`lZ~appTCOwP(%k$tH@T!w!ZyCP_j{@-aw61=$+{0deJ%UYIV&mR=k3{*s)mLuH8f;T$RxL?Cv#2&^=5WU@x3cSDQ%yOE&0||Vps$k`zfVqD zRpU(Fdp}jKzu;N}{95$)*y7%-YvXX1)2>yyew$8eP zu>i2N)CrusnM~f1u#m8kjHo@;dfLqci79IRF47r81N0@#$H)#;XU5Qq?ZvL%wu3b_ zwd|3%d3~K9@_G*>1%HZx#b+p&$?kHxXAHD6z_t76&OTCm95-4QWpScq6a8gopqL#3i&OJ-8)gYgz4RBhiK zMO9spyhOc^>b|!@jf%9CXJrQaYer>-&%c(8G%a==kzH|Mt(X&;5_`NmIA327tWlG7 zpxyrO3)f@^NAb3dyh-g4vj_WKysD0bG`l^qHhc7{yg^bG<6WH8olw^=G$g_{BmfhE z`%lvSTEpT2M@1LC-p{@SUt>Xw*Ng&XdSyOj+X%50dJ=JIedp3*ROMwyYb0=o6WAiF z&WOuL7$(?cuiHRZIbAt+IyyhMQm-9Wbv;gF4b8iN9W+$!Y&P>gB_dL}QdwaISlQ9e zI4W8&ZB_ra!*b*{={(MTxtIWwy)xnWK!J+TMQhS>RRTM*S8L_kCF{t*@x%EehtHrYKepFYe9O6?PYpn`s5S9U>M*REUJDgM0f5;Bi}r(&9vHo!5Ccu7aP;L^|aj^+{t|Z+ovwJFK4MfV-PSHjZn05(| zHHH@!3)t;g5UbRvfH=^-v99|Srh=SdD&R;u;5BLc{Q3o*a~}9J!%kalm*Q_Y zSKx6}pTb~RFf3rZr;@1W-LS0*U1g&1s+RU3^UEI%76a}9fL>6SNQ=4uU1nyy1z(7B GkNh7y-ONw` literal 0 HcmV?d00001