├── .gitattributes ├── .gitignore ├── .idea └── runConfigurations │ ├── failchat_dev.xml │ ├── failchat_dev__chat_only_.xml │ ├── funstream_html.xml │ ├── glass_html.xml │ └── old_sc2tv_html.xml ├── COPYING ├── README.md ├── build-runtime.sh ├── generate-jdeps-args.sh ├── pom.xml ├── run.sh ├── src ├── main │ ├── assembly │ │ └── zip.xml │ ├── external-resources │ │ ├── failchat.bat │ │ ├── failchat.sh │ │ ├── java-agents │ │ │ └── transparent-webview-patch.jar │ │ └── skins │ │ │ ├── _shared │ │ │ ├── common.css │ │ │ ├── demo-messages.js │ │ │ ├── example-messages.js │ │ │ ├── failchat.js │ │ │ ├── font │ │ │ │ └── icomoon.woff │ │ │ ├── icons │ │ │ │ ├── click-transparency-mode.svg │ │ │ │ ├── failchat.png │ │ │ │ ├── goodgame.png │ │ │ │ ├── twitch.png │ │ │ │ ├── youtube-moderator.svg │ │ │ │ ├── youtube-streamer.svg │ │ │ │ ├── youtube-verified.svg │ │ │ │ └── youtube.png │ │ │ └── templates │ │ │ │ ├── emoticon-raster.tmpl.html │ │ │ │ ├── emoticon-vector.tmpl.html │ │ │ │ ├── image.tmpl.html │ │ │ │ ├── link.tmpl.html │ │ │ │ ├── message.tmpl.html │ │ │ │ ├── origin-viewers-bar.tmpl.html │ │ │ │ └── status-message.tmpl.html │ │ │ ├── funstream │ │ │ ├── funstream.css │ │ │ ├── funstream.html │ │ │ └── icons │ │ │ │ ├── failchat.png │ │ │ │ ├── goodgame.png │ │ │ │ ├── twitch.png │ │ │ │ └── youtube.png │ │ │ ├── glass │ │ │ ├── glass.css │ │ │ └── glass.html │ │ │ └── old_sc2tv │ │ │ ├── old_sc2tv.css │ │ │ └── old_sc2tv.html │ ├── kotlin │ │ └── failchat │ │ │ ├── AppState.kt │ │ │ ├── AppStateManager.kt │ │ │ ├── ConfigKeys.kt │ │ │ ├── ConfigLoader.kt │ │ │ ├── ConfigUtils.kt │ │ │ ├── Dependencies.kt │ │ │ ├── FailchatMain.kt │ │ │ ├── FailchatServerInfo.kt │ │ │ ├── Logging.kt │ │ │ ├── Origin.kt │ │ │ ├── chat │ │ │ ├── AppConfiguration.kt │ │ │ ├── Author.kt │ │ │ ├── ChatClient.kt │ │ │ ├── ChatClientCallbacks.kt │ │ │ ├── ChatClientStatus.kt │ │ │ ├── ChatMessage.kt │ │ │ ├── ChatMessageHistory.kt │ │ │ ├── ChatMessageRemover.kt │ │ │ ├── ChatMessageSender.kt │ │ │ ├── DeletedMessagePlaceholder.kt │ │ │ ├── Elements.kt │ │ │ ├── Image.kt │ │ │ ├── ImageFormat.kt │ │ │ ├── Link.kt │ │ │ ├── MessageElement.kt │ │ │ ├── MessageFilter.kt │ │ │ ├── MessageHandler.kt │ │ │ ├── MessageIdGenerator.kt │ │ │ ├── OnChatMessageCallback.kt │ │ │ ├── OnChatMessageDeletedCallback.kt │ │ │ ├── OnStatusUpdateCallback.kt │ │ │ ├── OriginStatus.kt │ │ │ ├── OriginStatusManager.kt │ │ │ ├── StatusUpdate.kt │ │ │ ├── badge │ │ │ │ ├── Badge.kt │ │ │ │ ├── BadgeFinder.kt │ │ │ │ ├── BadgeManager.kt │ │ │ │ ├── BadgeOrigin.kt │ │ │ │ └── BadgeStorage.kt │ │ │ └── handlers │ │ │ │ ├── BraceEscaper.kt │ │ │ │ ├── ChatHistoryLogger.kt │ │ │ │ ├── CommaHighlightHandler.kt │ │ │ │ ├── ElementLabelEscaper.kt │ │ │ │ ├── EmojiHandler.kt │ │ │ │ ├── FailchatEmoticonHandler.kt │ │ │ │ ├── IgnoreFilter.kt │ │ │ │ ├── ImageLinkHandler.kt │ │ │ │ ├── LinkHandler.kt │ │ │ │ ├── OriginsStatusHandler.kt │ │ │ │ └── SpaceSeparatedEmoticonHandler.kt │ │ │ ├── emoticon │ │ │ ├── ChannelEmoticonUpdater.kt │ │ │ ├── DeletedMessagePlaceholderFactory.kt │ │ │ ├── EmojiEmoticon.kt │ │ │ ├── Emoticon.kt │ │ │ ├── EmoticonAndId.kt │ │ │ ├── EmoticonCodeIdDbCompactStorage.kt │ │ │ ├── EmoticonCodeIdDbStorage.kt │ │ │ ├── EmoticonCodeIdMemoryStorage.kt │ │ │ ├── EmoticonCodeMemoryStorage.kt │ │ │ ├── EmoticonFactory.kt │ │ │ ├── EmoticonFinder.kt │ │ │ ├── EmoticonIdAndCode.kt │ │ │ ├── EmoticonIdExtractor.kt │ │ │ ├── EmoticonLoadConfiguration.kt │ │ │ ├── EmoticonLoadException.kt │ │ │ ├── EmoticonLoader.kt │ │ │ ├── EmoticonManager.kt │ │ │ ├── EmoticonStorage.kt │ │ │ ├── EmptyEmoticonStorage.kt │ │ │ ├── FailchatEmoticon.kt │ │ │ ├── FailchatEmoticonScanner.kt │ │ │ ├── FailchatEmoticonUpdater.kt │ │ │ ├── GlobalEmoticonUpdater.kt │ │ │ ├── MapdbFactory.kt │ │ │ ├── OriginEmoticonStorage.kt │ │ │ ├── OriginEmoticonStorageFactory.kt │ │ │ ├── ReplaceDecision.kt │ │ │ ├── SemicolonCodeProcessor.kt │ │ │ ├── TwitchEmoticonFactory.kt │ │ │ └── WordReplacer.kt │ │ │ ├── exception │ │ │ ├── ChannelNotFoundException.kt │ │ │ ├── ChannelOfflineException.kt │ │ │ ├── DataNotFoundException.kt │ │ │ ├── InvalidConfigurationException.kt │ │ │ ├── NativeCallException.kt │ │ │ ├── UnexpectedResponseCodeException.kt │ │ │ ├── UnexpectedResponseException.kt │ │ │ └── UnexpectedWsMessage.kt │ │ │ ├── github │ │ │ ├── GithubClient.kt │ │ │ ├── NoReleasesFoundException.kt │ │ │ ├── Release.kt │ │ │ ├── ReleaseChecker.kt │ │ │ └── Version.kt │ │ │ ├── goodgame │ │ │ ├── GgApi2Client.kt │ │ │ ├── GgApiClient.kt │ │ │ ├── GgAuthorColorHandler.kt │ │ │ ├── GgBadgeHandler.kt │ │ │ ├── GgChannel.kt │ │ │ ├── GgChatClient.kt │ │ │ ├── GgColors.kt │ │ │ ├── GgEmoticon.kt │ │ │ ├── GgEmoticonHandler.kt │ │ │ ├── GgEmoticonIdExtractor.kt │ │ │ ├── GgEmoticonLoadConfiguration.kt │ │ │ ├── GgEmoticonLoader.kt │ │ │ ├── GgMessage.kt │ │ │ ├── GgViewersCountLoader.kt │ │ │ ├── HtmlUrlCleaner.kt │ │ │ └── StreamResponse.kt │ │ │ ├── gui │ │ │ ├── ChatFrame.kt │ │ │ ├── ChatFrameLauncher.kt │ │ │ ├── ChatGuiEventHandler.kt │ │ │ ├── ClickTransparencyConfigurator.kt │ │ │ ├── Extensions.kt │ │ │ ├── FullGuiEventHandler.kt │ │ │ ├── GuiEventHandlerIf.kt │ │ │ ├── GuiFrames.kt │ │ │ ├── GuiLauncher.kt │ │ │ ├── GuiMode.kt │ │ │ ├── Images.kt │ │ │ ├── PortBindAlert.kt │ │ │ ├── SettingsFrame.kt │ │ │ ├── SkinConverter.kt │ │ │ └── WebViewLogger.kt │ │ │ ├── platform │ │ │ └── windows │ │ │ │ ├── Windows.kt │ │ │ │ └── WindowsCtConfigurator.kt │ │ │ ├── skin │ │ │ ├── Skin.kt │ │ │ ├── SkinScanner.kt │ │ │ └── Skins.kt │ │ │ ├── twitch │ │ │ ├── AuthResponse.kt │ │ │ ├── BadgesResponse.kt │ │ │ ├── BttvApiClient.kt │ │ │ ├── BttvChannelNotFoundException.kt │ │ │ ├── BttvEmoticon.kt │ │ │ ├── BttvEmoticonHandler.kt │ │ │ ├── BttvEmoticonIdExtractor.kt │ │ │ ├── BttvGlobalEmoticonLoadConfiguration.kt │ │ │ ├── BttvGlobalEmoticonLoader.kt │ │ │ ├── ConfigurationTokenContainer.kt │ │ │ ├── EmotesResponse.kt │ │ │ ├── FfzApiClient.kt │ │ │ ├── FfzChannelNotFoundException.kt │ │ │ ├── FfzEmoticon.kt │ │ │ ├── FfzEmoticonHandler.kt │ │ │ ├── HelixApiToken.kt │ │ │ ├── HelixTokenContainer.kt │ │ │ ├── InvalidTokenException.kt │ │ │ ├── RangedEmoticon.kt │ │ │ ├── SevenTvApiClient.kt │ │ │ ├── SevenTvChannelNotFoundException.kt │ │ │ ├── SevenTvChannelResponse.kt │ │ │ ├── SevenTvEmoteSetResponse.kt │ │ │ ├── SevenTvEmoticon.kt │ │ │ ├── SevenTvEmoticonIdExtractor.kt │ │ │ ├── SevenTvGlobalEmoticonLoadConfiguration.kt │ │ │ ├── SevenTvGlobalEmoticonLoader.kt │ │ │ ├── StreamsResponse.kt │ │ │ ├── TokenAwareTwitchApiClient.kt │ │ │ ├── TwitchApiClient.kt │ │ │ ├── TwitchAuthorColorHandler.kt │ │ │ ├── TwitchBadgeHandler.kt │ │ │ ├── TwitchBadgeId.kt │ │ │ ├── TwitchChatClient.kt │ │ │ ├── TwitchEmotesTagParser.kt │ │ │ ├── TwitchEmoticon.kt │ │ │ ├── TwitchEmoticonHandler.kt │ │ │ ├── TwitchEmoticonIdExtractor.kt │ │ │ ├── TwitchEmoticonLoadConfiguration.kt │ │ │ ├── TwitchEmoticonUrlFactory.kt │ │ │ ├── TwitchGlobalEmoticonLoader.kt │ │ │ ├── TwitchHighlightByPointsHandler.kt │ │ │ ├── TwitchHighlightHandler.kt │ │ │ ├── TwitchIrcTags.kt │ │ │ ├── TwitchMessage.kt │ │ │ ├── TwitchRetries.kt │ │ │ ├── TwitchRewardHandler.kt │ │ │ ├── TwitchViewersCountLoader.kt │ │ │ └── UsersResponse.kt │ │ │ ├── util │ │ │ ├── Collections.kt │ │ │ ├── CompletableFutures.kt │ │ │ ├── Concurrent.kt │ │ │ ├── Config.kt │ │ │ ├── Coroutines.kt │ │ │ ├── Eithers.kt │ │ │ ├── Files.kt │ │ │ ├── Ints.kt │ │ │ ├── Jackson.kt │ │ │ ├── JavaFx.kt │ │ │ ├── Json.kt │ │ │ ├── Lang.kt │ │ │ ├── LateinitVal.kt │ │ │ ├── OkHttp.kt │ │ │ ├── OkHttpLogger.kt │ │ │ ├── Streams.kt │ │ │ ├── Strings.kt │ │ │ ├── Systems.kt │ │ │ ├── Threads.kt │ │ │ └── Urls.kt │ │ │ ├── viewers │ │ │ ├── CountableOrigins.kt │ │ │ ├── ViewersCountLoader.kt │ │ │ ├── ViewersCountWsHandler.kt │ │ │ └── ViewersCounter.kt │ │ │ ├── ws │ │ │ ├── client │ │ │ │ └── WsClient.kt │ │ │ └── server │ │ │ │ ├── ClientConfigurationWsHandler.kt │ │ │ │ ├── DeleteWsMessageHandler.kt │ │ │ │ ├── IgnoreWsMessageHandler.kt │ │ │ │ ├── InboundWsMessage.kt │ │ │ │ ├── WsFrameSender.kt │ │ │ │ ├── WsMessageDispatcher.kt │ │ │ │ └── WsMessageHandler.kt │ │ │ └── youtube │ │ │ ├── LiveChatRequest.kt │ │ │ ├── LiveChatRequestParameters.kt │ │ │ ├── LiveChatResponse.kt │ │ │ ├── MetadataResponse.kt │ │ │ ├── RoleBadges.kt │ │ │ ├── StreamStatus.kt │ │ │ ├── UpdatedMetadataRequest.kt │ │ │ ├── YoutubeChatClient.kt │ │ │ ├── YoutubeClient.kt │ │ │ ├── YoutubeClientException.kt │ │ │ ├── YoutubeColors.kt │ │ │ ├── YoutubeEmoticon.kt │ │ │ ├── YoutubeHighlightHandler.kt │ │ │ ├── YoutubeHtmlParser.kt │ │ │ ├── YoutubeMessage.kt │ │ │ ├── YoutubeViewCountParser.kt │ │ │ └── YoutubeViewersCountLoader.kt │ └── resources │ │ ├── config │ │ └── default.properties │ │ ├── fx │ │ └── settings.fxml │ │ └── icons │ │ ├── click-transparency-mode.png │ │ ├── failchat.png │ │ ├── funstream.png │ │ ├── goodgame.png │ │ ├── twitch.png │ │ └── youtube.png └── test │ ├── kotlin │ └── failchat │ │ ├── Configs.kt │ │ ├── Shared.kt │ │ ├── Utils.kt │ │ ├── chat │ │ └── handlers │ │ │ └── EmojiHandlerTest.kt │ │ ├── emoticon │ │ ├── FailchatEmoticonScannerTest.kt │ │ └── WordReplacerTest.kt │ │ ├── experiment │ │ ├── EmojiLibTest.kt │ │ ├── Jna.kt │ │ ├── OkHttpWsClient.kt │ │ └── YoutubeV2.kt │ │ ├── github │ │ └── GithubClientTest.kt │ │ ├── goodgame │ │ ├── GgApi2ClientTest.kt │ │ └── GgApiClientTest.kt │ │ ├── gui │ │ ├── LoadingPopupTest.kt │ │ └── SkinConverterTest.kt │ │ ├── handlers │ │ └── SemicolonCodeProcessorTest.kt │ │ ├── twitch │ │ ├── BttvApiClientTest.kt │ │ ├── FfzApiClientTest.kt │ │ ├── SevenTvApiClientTest.kt │ │ ├── TwitchApiClientTest.kt │ │ ├── TwitchEmoticonHandlerTest.kt │ │ └── TwitchRetriesTest.kt │ │ ├── util │ │ └── StackTraceFormatTest.kt │ │ └── youtube │ │ ├── YoutubeClientTest.kt │ │ ├── YoutubeHtmlParserTest.kt │ │ └── YoutubeViewCountParserTest.kt │ └── resources │ ├── failchat-emoticons │ ├── 1.jpg │ ├── 2.jpeg │ ├── 3.png │ ├── 4.svg │ ├── 5.gif │ ├── UPPERCASE.JPG │ ├── minus-.jpg │ ├── so.many.dots.jpg │ ├── underscore_.jpg │ ├── unknown-format │ └── unsupported-format.img │ ├── html │ └── live_chat.html │ └── mockito-extensions │ └── org.mockito.plugins.MockMaker └── websocket-api.md /.gitattributes: -------------------------------------------------------------------------------- 1 | src/test/resources/** linguist-vendored 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | src/main/resources/config/private.properties 3 | 4 | #idea 5 | .idea/* 6 | !.idea/runConfigurations/ 7 | failchat.iml 8 | 9 | #etc 10 | tree 11 | docs/* 12 | jdk 13 | -------------------------------------------------------------------------------- /.idea/runConfigurations/failchat_dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations/failchat_dev__chat_only_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations/funstream_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/runConfigurations/glass_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/runConfigurations/old_sc2tv_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Failchat is a desktop application for streamers. It aggregates chat messages from multiple sources, shows you viewer 2 | count, and more. 3 | Detailed description could be found [on the site](https://onoderis.github.io/failchat/). 4 | 5 | ### Before you run or build 6 | 7 | 1. Java 11 with bundled JavaFX is 8 | required. [Liberica full JDK 11.0.22+12](https://bell-sw.com/pages/downloads/?version=java-11&release=11.0.22%2B12) 9 | is 10 | recommended. 11 | 12 | 13 | 2. Create a file `src/main/resources/config/private.properties` with the following properties and replace the values: 14 | 15 | ```properties 16 | twitch.bot-name=BOT_NAME 17 | twitch.bot-password=BOT_PASSWORD (has prefix "oauth:") 18 | twitch.client-id=API_TOKEN 19 | twitch.client-secret=CLIENT_SECRET 20 | ``` 21 | 22 | 3. In order to do `mvn package` you have to put desired JDK to `jdk/` directory. See goal `build-app-runtime` in pom.xml 23 | for additional info. 24 | 25 | ### How to run 26 | 27 | ```shell 28 | ./run.sh 29 | ``` 30 | 31 | ### How to build a distributable archive 32 | 33 | ```shell 34 | mvn package 35 | ``` 36 | -------------------------------------------------------------------------------- /build-runtime.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | jlink --no-header-files \ 3 | --no-man-pages \ 4 | --compress=2 \ 5 | --strip-debug \ 6 | --add-modules java.compiler,java.datatransfer,java.desktop,java.instrument,java.logging,java.management,java.naming,java.sql,java.xml,javafx.base,javafx.controls,javafx.fxml,javafx.graphics,javafx.web,jdk.attach,jdk.jdi,jdk.jsobject,jdk.unsupported,jdk.crypto.ec \ 7 | --module-path jdk/bellsoft-jdk11.0.22+12-windows-amd64-full/jmods \ 8 | --output win-runtime 9 | -------------------------------------------------------------------------------- /generate-jdeps-args.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | VERSION=$(cat pom.xml | xq -e /project/version) 3 | 4 | #mvn package 5 | 6 | # TODO fix this command 7 | jdeps --list-deps target/failchat-v"$VERSION"/failchat-"$VERSION".jar | \ 8 | sed 's/ //g' | \ 9 | sed '/JDK removed internal API/d' | \ 10 | sed '/java.base\//d' | \ 11 | tr '\n' ',' | \ 12 | sed 's/$/jdk.crypto.ec/' 13 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mvn compile org.codehaus.mojo:exec-maven-plugin:exec@run-app 3 | -------------------------------------------------------------------------------- /src/main/assembly/zip.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | zip 5 | 6 | zip 7 | 8 | failchat-v${project.version} 9 | 10 | 11 | ${project.build.directory}/failchat-v${project.version} 12 | ./ 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/external-resources/failchat.bat: -------------------------------------------------------------------------------- 1 | start ./runtime/bin/javaw -Xmx200m -Xms100m -XX:+UseG1GC -javaagent:java-agents/transparent-webview-patch.jar -jar failchat-${project.version}.jar 2 | -------------------------------------------------------------------------------- /src/main/external-resources/failchat.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | java -Xmx200m -Xms100m -XX:+UseG1GC -javaagent:java-agents/transparent-webview-patch.jar -jar failchat-${project.version}.jar > /dev/null 2>&1 & 3 | -------------------------------------------------------------------------------- /src/main/external-resources/java-agents/transparent-webview-patch.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/java-agents/transparent-webview-patch.jar -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/font/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/_shared/font/icomoon.woff -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/click-transparency-mode.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/failchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/_shared/icons/failchat.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/goodgame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/_shared/icons/goodgame.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/twitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/_shared/icons/twitch.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/youtube-moderator.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/youtube-streamer.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/youtube-verified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/_shared/icons/youtube.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/emoticon-raster.tmpl.html: -------------------------------------------------------------------------------- 1 | {{:code}} 2 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/emoticon-vector.tmpl.html: -------------------------------------------------------------------------------- 1 | {{:code}}>
2 | 


--------------------------------------------------------------------------------
/src/main/external-resources/skins/_shared/templates/image.tmpl.html:
--------------------------------------------------------------------------------
1 | <div class= 2 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/link.tmpl.html: -------------------------------------------------------------------------------- 1 | {{:domain}} 2 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/message.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 | {{for badges}} 6 | {{if type === "image"}} 7 | 8 | {{else type === "character"}} 9 | {{:htmlEntity}} 10 | {{/if}} 11 | {{/for}} 12 |
13 |
14 |
15 | {{:author.name}} 16 |
17 | 18 | 🚫 19 |
20 | {{:text}} 21 |
22 |
23 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/origin-viewers-bar.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/status-message.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | {{:origin}} 7 | {{:status}} 8 |
9 |
10 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/funstream.css: -------------------------------------------------------------------------------- 1 | /* Scrollbar */ 2 | .baron .scrollbar { 3 | background: #FFDB5F; 4 | } 5 | 6 | body { 7 | color: white; 8 | } 9 | 10 | /* Viewers bar */ 11 | 12 | .viewers-bar { 13 | background-color: rgba(16, 21, 28, 0.9); 14 | border: 2px solid rgba(16, 21, 28, 1); 15 | border-radius: 12px; 16 | margin-bottom: 1px; 17 | } 18 | 19 | .viewers-bar.on + .chat-wrapper { 20 | height: calc(100% - 41px); 21 | } 22 | 23 | /* Message */ 24 | 25 | .message { 26 | background-color: rgba(16, 21, 28, 0.9); 27 | margin: 0 0 3px 0; 28 | border: 2px solid rgba(16, 21, 28, 1); 29 | border-radius: 12px; 30 | position: relative; 31 | animation: appear-bottom 0.4s; 32 | color: white; 33 | } 34 | 35 | span.nick, span.origin-name, span.status-text { 36 | color: #FFDB5F; 37 | } 38 | 39 | span.highlighted { 40 | color: #74CD36; 41 | } 42 | 43 | .message a { 44 | color: #7FCDFF; 45 | } 46 | 47 | .hiding { 48 | animation: disappear-top 0.4s; 49 | } 50 | 51 | /* Animations */ 52 | 53 | @keyframes appear-bottom { 54 | from { 55 | bottom: -100px; 56 | opacity: 0 57 | } 58 | to { 59 | bottom: 0; 60 | opacity: 1 61 | } 62 | } 63 | 64 | @keyframes disappear-top { 65 | from { 66 | top: 0; 67 | opacity: 1 68 | } 69 | to { 70 | top: -100px; 71 | opacity: 0 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/funstream.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | failchat 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 32 | 33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/failchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/funstream/icons/failchat.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/goodgame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/funstream/icons/goodgame.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/twitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/funstream/icons/twitch.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/external-resources/skins/funstream/icons/youtube.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/glass/glass.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | failchat 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 32 | 33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/old_sc2tv/old_sc2tv.css: -------------------------------------------------------------------------------- 1 | .message, .viewers-bar { 2 | text-shadow: 0 0 0 black; /* there is difference on white background */ 3 | } 4 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/old_sc2tv/old_sc2tv.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | failchat 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/AppState.kt: -------------------------------------------------------------------------------- 1 | package failchat 2 | 3 | enum class AppState { 4 | SETTINGS, 5 | CHAT 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/ConfigUtils.kt: -------------------------------------------------------------------------------- 1 | package failchat 2 | 3 | import failchat.emoticon.OriginEmoticonStorageFactory 4 | import org.apache.commons.configuration2.Configuration 5 | import java.nio.file.Path 6 | import java.nio.file.Paths 7 | 8 | fun Configuration.resetEmoticonsUpdatedTime() { 9 | OriginEmoticonStorageFactory.dbOrigins.forEach { 10 | this.setProperty(ConfigKeys.lastUpdatedEmoticons(it), 0) 11 | } 12 | } 13 | 14 | val workingDirectory: Path = Paths.get("") 15 | val failchatHomePath: Path = Paths.get(System.getProperty("user.home")).resolve(".failchat") 16 | val failchatEmoticonsDirectory: Path = failchatHomePath.resolve("failchat-emoticons") 17 | val emoticonCacheDirectory: Path = workingDirectory.resolve("emoticons") 18 | val emoticonDbFile: Path = emoticonCacheDirectory.resolve("emoticons.db") 19 | 20 | val failchatEmoticonsUrl = "http://${FailchatServerInfo.host.hostAddress}:${FailchatServerInfo.port}/emoticons/" 21 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/FailchatServerInfo.kt: -------------------------------------------------------------------------------- 1 | package failchat 2 | 3 | import java.net.InetAddress 4 | 5 | object FailchatServerInfo { 6 | val host: InetAddress = InetAddress.getLoopbackAddress() 7 | const val defaultPort = 10880 8 | var port = defaultPort 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/Origin.kt: -------------------------------------------------------------------------------- 1 | package failchat 2 | 3 | /** 4 | * Первоисточник сообщений / emoticon'ов. 5 | */ 6 | enum class Origin(val commonName: String) { //todo rename to MessageOrigin, remove BTTV 7 | GOODGAME("goodgame"), 8 | TWITCH("twitch"), 9 | BTTV_GLOBAL("bttvGlobal"), //todo refactor? 10 | BTTV_CHANNEL("bttvChannel"), 11 | FRANKERFASEZ("frankerfacez"), 12 | SEVEN_TV_GLOBAL("7tvGlobal"), 13 | SEVEN_TV_CHANNEL("7tvChannel"), 14 | YOUTUBE("youtube"), 15 | FAILCHAT("failchat"); 16 | 17 | 18 | companion object { 19 | val values: List = Origin.values().toList() 20 | 21 | private val map: Map = values().map { it.commonName to it }.toMap() 22 | 23 | fun byCommonName(name: String): Origin { 24 | return map[name] ?: throw IllegalArgumentException("No origin found with name '$name'") 25 | } 26 | } 27 | } 28 | 29 | val chatOrigins = listOf( 30 | Origin.GOODGAME, 31 | Origin.TWITCH, 32 | Origin.YOUTUBE 33 | ) 34 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/AppConfiguration.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import org.apache.commons.configuration2.Configuration 4 | 5 | class AppConfiguration( 6 | val config: Configuration 7 | ) { 8 | @Volatile 9 | var deletedMessagePlaceholder = DeletedMessagePlaceholder("message deleted", listOf()) 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/Author.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.Origin 4 | import javafx.scene.paint.Color 5 | 6 | data class Author( 7 | /** Author's name. */ 8 | val name: String, 9 | 10 | /** Author's origin. */ 11 | val origin: Origin, 12 | 13 | /** Origin specific id. */ 14 | val id: String = name, 15 | 16 | /** Author's nickname color. */ 17 | var color: Color? = null 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ChatClient.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.Origin 4 | 5 | /** Base interface for a chat client. Implementation should not be reusable. */ 6 | interface ChatClient { 7 | 8 | val origin: Origin 9 | val status: ChatClientStatus 10 | 11 | val callbacks: ChatClientCallbacks 12 | 13 | fun start() 14 | fun stop() 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ChatClientCallbacks.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | class ChatClientCallbacks( 4 | val onChatMessage: (ChatMessage) -> Unit, 5 | val onStatusUpdate: (StatusUpdate) -> Unit, 6 | val onChatMessageDeleted: (ChatMessage) -> Unit 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ChatClientStatus.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | enum class ChatClientStatus { 4 | READY, 5 | CONNECTING, 6 | CONNECTED, 7 | ERROR, 8 | OFFLINE 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ChatMessage.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.Origin 4 | import failchat.chat.badge.Badge 5 | import failchat.emoticon.Emoticon 6 | import java.time.Instant 7 | 8 | /** 9 | * Сообщение из чата какого-либо первоисточника. 10 | * */ 11 | open class ChatMessage( 12 | /** Внутренний id, генерируемый приложением. */ 13 | val id: Long, 14 | 15 | /** Первоисточник сообщения. */ 16 | val origin: Origin, 17 | 18 | /** Автор сообщения. */ 19 | val author: Author, 20 | 21 | /** Текст сообщения. */ 22 | var text: String, 23 | 24 | /** Время получения сообщения. */ 25 | val timestamp: Instant = Instant.now() 26 | ) { 27 | 28 | /** 29 | * Could contains next elements: 30 | * - [Emoticon] 31 | * - [Link] 32 | * - [Image] 33 | * */ 34 | val elements: List 35 | get() = mutableElements 36 | private val mutableElements: MutableList = ArrayList(5) 37 | 38 | /** Badges of the message. */ 39 | val badges: List 40 | get() = mutableBadges 41 | private val mutableBadges: MutableList = ArrayList(3) 42 | 43 | var highlighted: Boolean = false 44 | var highlightedBackground: Boolean = false 45 | 46 | /** 47 | * @return formatted string for added element. 48 | * */ 49 | fun addElement(element: MessageElement): String { 50 | mutableElements.add(element) 51 | return Elements.label(mutableElements.size - 1) 52 | } 53 | 54 | fun replaceElement(index: Int, replacement: MessageElement): Any? { 55 | return mutableElements.set(index, replacement) 56 | } 57 | 58 | fun addBadge(badge: Badge) { 59 | mutableBadges.add(badge) 60 | } 61 | 62 | override fun toString(): String { 63 | return "ChatMessage(id=$id, origin=$origin, author=$author, text='$text', timestamp=$timestamp, " + 64 | "badges=$badges, mutableElements=$mutableElements, highlighted=$highlighted, " + 65 | "highlightedBackground=$highlightedBackground)" 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ChatMessageRemover.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory 4 | 5 | class ChatMessageRemover( 6 | private val chatMessageSender: ChatMessageSender 7 | ) { 8 | 9 | private val nodeFactory: JsonNodeFactory = JsonNodeFactory.instance 10 | 11 | fun remove(messageId: Long) { 12 | val removeMessage = nodeFactory.objectNode().apply { 13 | put("type", "delete-message") 14 | putObject("content").apply { 15 | put("messageId", messageId) 16 | } 17 | } 18 | chatMessageSender.send(removeMessage) 19 | } 20 | 21 | fun remove(message: ChatMessage) = remove(message.id) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/DeletedMessagePlaceholder.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.emoticon.Emoticon 4 | 5 | class DeletedMessagePlaceholder( 6 | val text: String, 7 | val emoticons: List 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/Elements.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | object Elements { 4 | 5 | /** Get string representation of element by it's number. */ 6 | fun label(number: Int) = "{!$number}" 7 | 8 | fun escapeBraces(text: String): String { 9 | return text 10 | .replace("<", "<") 11 | .replace(">", ">") 12 | } 13 | 14 | fun escapeLabelCharacters(text: String): String { 15 | return text 16 | .replace("{", "{") 17 | .replace("}", "}") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/Image.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | data class Image( 4 | val link: Link 5 | ) : MessageElement 6 | 7 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/ImageFormat.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | enum class ImageFormat(val jsonValue: String) { 4 | RASTER("raster"), 5 | VECTOR("vector") 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/Link.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | /** 4 | * Класс, сериализующийся в json для отправки к websocket клиентам. 5 | */ 6 | data class Link( 7 | val fullUrl: String, 8 | val domain: String, 9 | val shortUrl: String 10 | ) : MessageElement 11 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/MessageElement.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | //todo sealed class 4 | /** Indicator for chat message elements. */ 5 | interface MessageElement 6 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/MessageFilter.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | interface MessageFilter { 4 | /** 5 | * @return true if message should be dropped 6 | * * 7 | */ 8 | fun filterMessage(message: T): Boolean 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/MessageHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | interface MessageHandler { 4 | fun handleMessage(message: T) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/MessageIdGenerator.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import java.util.concurrent.atomic.AtomicLong 4 | 5 | class MessageIdGenerator(lastId: Long) { 6 | 7 | private val _lastId: AtomicLong = AtomicLong(lastId) 8 | 9 | val lastId: Long get() = _lastId.get() 10 | 11 | fun generate() = _lastId.getAndIncrement() 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/OnChatMessageCallback.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import kotlinx.coroutines.runBlocking 4 | 5 | class OnChatMessageCallback( 6 | private val filters: List>, 7 | private val handlers: List>, 8 | private val messageHistory: ChatMessageHistory, 9 | private val messageSender: ChatMessageSender 10 | ) : (ChatMessage) -> Unit { 11 | 12 | override fun invoke(message: ChatMessage) { 13 | // apply filters and handlers 14 | filters.forEach { 15 | if (it.filterMessage(message)) return 16 | } 17 | handlers.forEach { it.handleMessage(message) } 18 | 19 | runBlocking { 20 | messageHistory.add(message) 21 | } 22 | 23 | messageSender.send(message) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/OnChatMessageDeletedCallback.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | class OnChatMessageDeletedCallback( 4 | private val chatMessageRemover: ChatMessageRemover 5 | ) : (ChatMessage) -> Unit { 6 | 7 | override fun invoke(message: ChatMessage) { 8 | chatMessageRemover.remove(message) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/OnStatusUpdateCallback.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | class OnStatusUpdateCallback( 4 | private val originStatusManager: OriginStatusManager 5 | ) : (StatusUpdate) -> Unit { 6 | 7 | override fun invoke(message: StatusUpdate) { 8 | originStatusManager.setStatus(message.origin, message.status) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/OriginStatus.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | enum class OriginStatus(val jsonValue: String) { 4 | CONNECTED("connected"), 5 | DISCONNECTED("disconnected") 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/OriginStatusManager.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.Origin 4 | import failchat.chat.OriginStatus.DISCONNECTED 5 | import failchat.chatOrigins 6 | import failchat.util.enumMap 7 | import java.util.EnumMap 8 | import java.util.concurrent.locks.ReentrantReadWriteLock 9 | import kotlin.concurrent.read 10 | import kotlin.concurrent.write 11 | 12 | class OriginStatusManager( 13 | private val messageSender: ChatMessageSender 14 | ) { 15 | 16 | private companion object { 17 | val allDisconnected: Map = enumMap().also { map -> 18 | chatOrigins.forEach { origin -> 19 | map[origin] = DISCONNECTED 20 | } 21 | } 22 | } 23 | 24 | private val statuses: EnumMap = enumMap() 25 | private val lock = ReentrantReadWriteLock() 26 | 27 | init { 28 | chatOrigins.forEach { 29 | statuses[it] = DISCONNECTED 30 | } 31 | } 32 | 33 | fun getStatuses(): Map { 34 | return lock.read { 35 | cloneStatuses() 36 | } 37 | } 38 | 39 | fun setStatus(origin: Origin, status: OriginStatus) { 40 | val afterMap = lock.write { 41 | statuses[origin] = status 42 | cloneStatuses() 43 | } 44 | messageSender.sendConnectedOriginsMessage(afterMap) 45 | } 46 | 47 | fun reset() { 48 | lock.write { 49 | statuses.entries.forEach { 50 | it.setValue(DISCONNECTED) 51 | } 52 | } 53 | messageSender.sendConnectedOriginsMessage(allDisconnected) 54 | } 55 | 56 | private fun cloneStatuses(): EnumMap { 57 | return EnumMap(statuses) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/StatusUpdate.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | import failchat.Origin 4 | 5 | data class StatusUpdate( 6 | val origin: Origin, 7 | val status: OriginStatus 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/badge/Badge.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.badge 2 | 3 | import failchat.chat.ImageFormat 4 | import javafx.scene.paint.Color 5 | 6 | sealed class Badge { 7 | abstract val description: String? 8 | } 9 | 10 | data class ImageBadge( 11 | val url: String, 12 | val format: ImageFormat, 13 | override val description: String? = null 14 | ) : Badge() 15 | 16 | data class CharacterBadge( 17 | /** Html character entity. */ 18 | val characterEntity: String, 19 | /** Color in hexadecimal format. */ 20 | val color: Color, 21 | override val description: String? = null 22 | ) : Badge() 23 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/badge/BadgeFinder.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.badge 2 | 3 | interface BadgeFinder { 4 | fun findBadge(origin: BadgeOrigin, badgeId: Any): Badge? 5 | } -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/badge/BadgeManager.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.badge 2 | 3 | import failchat.chat.badge.BadgeOrigin.TWITCH_CHANNEL 4 | import failchat.chat.badge.BadgeOrigin.TWITCH_GLOBAL 5 | import failchat.twitch.TokenAwareTwitchApiClient 6 | import failchat.util.CoroutineExceptionLogger 7 | import kotlinx.coroutines.CoroutineScope 8 | import kotlinx.coroutines.Deferred 9 | import kotlinx.coroutines.Dispatchers 10 | import kotlinx.coroutines.async 11 | import mu.KotlinLogging 12 | 13 | class BadgeManager( 14 | private val badgeStorage: BadgeStorage, 15 | private val twitchApiClient: TokenAwareTwitchApiClient 16 | ) { 17 | 18 | private companion object { 19 | val logger = KotlinLogging.logger {} 20 | } 21 | 22 | suspend fun loadGlobalBadges() { 23 | val jobsList: MutableList> = ArrayList() 24 | 25 | jobsList += CoroutineScope(Dispatchers.Default + CoroutineExceptionLogger).async { 26 | val twitchBadges = twitchApiClient.getGlobalBadges() 27 | logger.info("Global twitch badges was loaded. Count: {}", twitchBadges.size) 28 | badgeStorage.putBadges(TWITCH_GLOBAL, twitchBadges) 29 | } 30 | 31 | jobsList.forEach { it.join() } 32 | } 33 | 34 | suspend fun loadTwitchChannelBadges(channelId: Long) { 35 | val twitchBadges = twitchApiClient.getChannelBadges(channelId) 36 | logger.info("Channel badges was received for twitch channel '{}'. Count: {}", channelId, twitchBadges.size) 37 | 38 | badgeStorage.putBadges(TWITCH_CHANNEL, twitchBadges) 39 | } 40 | 41 | fun resetChannelBadges() { 42 | badgeStorage.putBadges(TWITCH_CHANNEL, emptyMap()) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/badge/BadgeOrigin.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.badge 2 | 3 | enum class BadgeOrigin { 4 | TWITCH_GLOBAL, 5 | TWITCH_CHANNEL, 6 | GOODGAME 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/badge/BadgeStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.badge 2 | 3 | import java.util.concurrent.ConcurrentHashMap 4 | 5 | class BadgeStorage : BadgeFinder { 6 | 7 | private val badgesMap: MutableMap> = ConcurrentHashMap() 8 | 9 | override fun findBadge(origin: BadgeOrigin, badgeId: Any): Badge? { 10 | return badgesMap.get(origin)?.get(badgeId) 11 | } 12 | 13 | fun putBadges(origin: BadgeOrigin, badges: Map) { 14 | badgesMap.put(origin, badges) 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/BraceEscaper.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.Elements 5 | import failchat.chat.MessageHandler 6 | 7 | /** 8 | * Заменяет символы '<' и '>' на html character entities. 9 | */ 10 | class BraceEscaper : MessageHandler { 11 | override fun handleMessage(message: ChatMessage) { 12 | message.text = Elements.escapeBraces(message.text) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/ChatHistoryLogger.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.Elements 5 | import failchat.chat.Image 6 | import failchat.chat.Link 7 | import failchat.chat.MessageHandler 8 | import failchat.emoticon.Emoticon 9 | import mu.KotlinLogging 10 | import java.io.BufferedWriter 11 | import java.time.ZoneId 12 | import java.time.format.DateTimeFormatter 13 | import java.time.temporal.ChronoUnit 14 | 15 | class ChatHistoryLogger(private val chatHistoryWriter: BufferedWriter) : MessageHandler { 16 | 17 | private companion object { 18 | val logger = KotlinLogging.logger {} 19 | val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME 20 | val zone: ZoneId = ZoneId.systemDefault() 21 | } 22 | 23 | override fun handleMessage(message: ChatMessage) { 24 | val textualMessage = message.elements.foldIndexed(message.text) { index, text, element -> 25 | val replacement: String = when (element) { 26 | is Emoticon -> element.code 27 | is Link -> element.fullUrl 28 | is Image -> element.link.fullUrl 29 | else -> { 30 | logger.error("Unknown element type: {}", element.javaClass.name) 31 | "" 32 | } 33 | } 34 | 35 | text.replace(Elements.label(index), replacement) 36 | } 37 | 38 | val time = message.timestamp.truncatedTo(ChronoUnit.SECONDS).atZone(zone) 39 | chatHistoryWriter.appendLine("${dateTimeFormatter.format(time)} [${message.origin.commonName}] " + 40 | "${message.author.name}: $textualMessage") 41 | chatHistoryWriter.flush() 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/CommaHighlightHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.MessageHandler 5 | 6 | class CommaHighlightHandler(username: String) : MessageHandler 7 | where T : ChatMessage { 8 | 9 | private val appeal: String = username + ',' 10 | 11 | override fun handleMessage(message: T) { 12 | if (message.text.contains(appeal, ignoreCase = true)) { 13 | message.highlighted = true 14 | } 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/ElementLabelEscaper.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.Elements 5 | import failchat.chat.MessageHandler 6 | 7 | /** 8 | * Заменяет символы '{' и '}' на html entity. 9 | */ 10 | class ElementLabelEscaper : MessageHandler { 11 | 12 | override fun handleMessage(message: T) { 13 | message.text = Elements.escapeLabelCharacters(message.text) 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/EmojiHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import com.vdurmont.emoji.EmojiParser 4 | import failchat.chat.ChatMessage 5 | import failchat.chat.MessageHandler 6 | import failchat.emoticon.EmojiEmoticon 7 | import failchat.util.toCodePoint 8 | import failchat.util.toHexString 9 | 10 | /** 11 | * Searches for unicode emojis in message and replaces them with svg images. 12 | * */ 13 | class EmojiHandler : MessageHandler { 14 | 15 | override fun handleMessage(message: ChatMessage) { 16 | val transformedText = EmojiParser.parseFromUnicode(message.text) { 17 | val hexEmoji = toHex(it.emoji.unicode) 18 | val hexFitzpatrick: String? = it.fitzpatrick?.let { f -> 19 | toHex(f.unicode) 20 | } 21 | 22 | val emojiHexSequence = if (hexFitzpatrick == null) { 23 | hexEmoji 24 | } else { 25 | "$hexEmoji-$hexFitzpatrick" 26 | } 27 | 28 | val emoticonUrl = "https://cdnjs.cloudflare.com/ajax/libs/twemoji/13.0.1/svg/$emojiHexSequence.svg" 29 | val emoticon = EmojiEmoticon(it.emoji.description ?: "emoji", emoticonUrl) 30 | 31 | message.addElement(emoticon) 32 | } 33 | 34 | message.text = transformedText 35 | } 36 | 37 | private fun toHex(emojiCharacters: String): String { 38 | return emojiCharacters 39 | .windowed(2, 2, true) { 40 | when (it.length) { 41 | 1 -> it[0].toInt().toHexString() 42 | 2 -> toCodePoint(it[0], it[1]).toHexString() 43 | else -> error("Expected windows of 1..2 characters: '$emojiCharacters'") 44 | } 45 | } 46 | .joinToString(separator = "-") 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/FailchatEmoticonHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.Origin.FAILCHAT 4 | import failchat.chat.ChatMessage 5 | import failchat.chat.MessageHandler 6 | import failchat.emoticon.EmoticonFinder 7 | import failchat.emoticon.ReplaceDecision 8 | import failchat.emoticon.SemicolonCodeProcessor 9 | 10 | class FailchatEmoticonHandler( 11 | private val finder: EmoticonFinder 12 | ) : MessageHandler { 13 | 14 | override fun handleMessage(message: ChatMessage) { 15 | message.text = SemicolonCodeProcessor.process(message.text) { code -> 16 | val emoticon = finder.findByCode(FAILCHAT, code) 17 | if (emoticon != null) { 18 | val label = message.addElement(emoticon) 19 | ReplaceDecision.Replace(label) 20 | } else { 21 | ReplaceDecision.Skip 22 | } 23 | } 24 | } 25 | 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/IgnoreFilter.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.ConfigKeys 4 | import failchat.Origin 5 | import failchat.chat.Author 6 | import failchat.chat.ChatMessage 7 | import failchat.chat.MessageFilter 8 | import failchat.chatOrigins 9 | import failchat.util.value 10 | import mu.KotlinLogging 11 | import org.apache.commons.configuration2.Configuration 12 | import java.util.concurrent.atomic.AtomicReference 13 | import java.util.regex.Pattern 14 | 15 | /** 16 | * Фильтрует сообщения от пользователей в игнор-листе. 17 | * Баны хранятся в формате 'authorId#origin (optionalAuthorName)'. 18 | */ 19 | class IgnoreFilter(private val config: Configuration) : MessageFilter { 20 | 21 | private companion object { 22 | val logger = KotlinLogging.logger {} 23 | } 24 | 25 | private val ignoreStringPattern: Pattern = compilePattern() 26 | 27 | private var ignoreSet: AtomicReference> = AtomicReference(emptySet()) 28 | 29 | init { 30 | reloadConfig() 31 | } 32 | 33 | override fun filterMessage(message: ChatMessage): Boolean { 34 | val ignoreMessage = ignoreSet.value.asSequence() 35 | .filter { it.id == message.author.id && it.origin == message.author.origin } 36 | .any() 37 | 38 | if (ignoreMessage) logger.debug { "Message filtered by ignore filter: $message" } 39 | return ignoreMessage 40 | } 41 | 42 | fun reloadConfig() { 43 | ignoreSet.value = config.getStringArray(ConfigKeys.ignore).asSequence() 44 | .map { it to ignoreStringPattern.matcher(it) } 45 | .filter { (ignoreEntry, matcher) -> 46 | matcher.find().also { found -> 47 | if (!found) logger.warn("Ignore entry skipped: '{}'", ignoreEntry) 48 | } 49 | } 50 | .map { (_, matcher) -> 51 | val id = matcher.group("id") 52 | val name = matcher.group("name") ?: id 53 | Author(name, Origin.byCommonName(matcher.group("origin")), id) 54 | } 55 | .toSet() 56 | logger.debug("IgnoreFilter reloaded a config") 57 | } 58 | 59 | private fun compilePattern(): Pattern { 60 | val originsPattern = chatOrigins 61 | .map { it.commonName } 62 | .joinToString(separator = "|", prefix = "(", postfix = ")") 63 | 64 | return Pattern.compile("""(?.+)#(?$originsPattern)( \\((?.*)\\))?""") 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/ImageLinkHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.Image 5 | import failchat.chat.Link 6 | import failchat.chat.MessageHandler 7 | 8 | /** 9 | * Заменяет элементы типа [Link] на [Image] в зависимости от конфигурации. 10 | * */ 11 | class ImageLinkHandler : MessageHandler { 12 | 13 | private companion object { 14 | val imageFormats = listOf(".jpg", ".jpeg", ".png", ".gif") 15 | } 16 | 17 | @Volatile 18 | var replaceImageLinks = false 19 | 20 | override fun handleMessage(message: ChatMessage) { 21 | if (!replaceImageLinks) return 22 | 23 | message.elements.forEachIndexed { index, element -> 24 | if (element !is Link) return@forEachIndexed 25 | 26 | val imageFormat = imageFormats.firstOrNull { 27 | element.fullUrl.endsWith(it, ignoreCase = true) 28 | } 29 | 30 | if (imageFormat != null) { 31 | message.replaceElement(index, Image(element)) 32 | } 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/LinkHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessage 4 | import failchat.chat.Link 5 | import failchat.chat.MessageHandler 6 | import failchat.util.urlPattern 7 | 8 | class LinkHandler : MessageHandler { 9 | 10 | override fun handleMessage(message: ChatMessage) { 11 | val matcher = urlPattern.matcher(message.text) 12 | while (matcher.find()) { 13 | val url = Link(matcher.group(), matcher.group(4), matcher.group(3)) 14 | val elementNumber = message.addElement(url) 15 | 16 | message.text = matcher.replaceFirst(elementNumber) 17 | matcher.reset(message.text) 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/OriginsStatusHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.chat.ChatMessageSender 4 | import failchat.chat.OriginStatusManager 5 | import failchat.ws.server.InboundWsMessage 6 | import failchat.ws.server.InboundWsMessage.Type.ORIGINS_STATUS 7 | import failchat.ws.server.WsMessageHandler 8 | 9 | class OriginsStatusHandler( 10 | private val originStatusManager: OriginStatusManager, 11 | private val messageSender: ChatMessageSender 12 | ) : WsMessageHandler { 13 | 14 | override val expectedType = ORIGINS_STATUS 15 | 16 | override fun handle(message: InboundWsMessage) { 17 | messageSender.sendConnectedOriginsMessage(originStatusManager.getStatuses()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/chat/handlers/SpaceSeparatedEmoticonHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat.handlers 2 | 3 | import failchat.Origin 4 | import failchat.chat.ChatMessage 5 | import failchat.chat.MessageHandler 6 | import failchat.emoticon.EmoticonFinder 7 | import failchat.emoticon.ReplaceDecision 8 | import failchat.emoticon.WordReplacer 9 | 10 | class SpaceSeparatedEmoticonHandler( 11 | private val origin: Origin, 12 | private val emoticonFinder: EmoticonFinder 13 | ) : MessageHandler { 14 | 15 | override fun handleMessage(message: ChatMessage) { 16 | message.text = WordReplacer.replace(message.text) { code -> 17 | val emoticon = emoticonFinder.findByCode(origin, code) 18 | ?: return@replace ReplaceDecision.Skip 19 | val label = message.addElement(emoticon) 20 | return@replace ReplaceDecision.Replace(label) 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/DeletedMessagePlaceholderFactory.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.ConfigKeys 4 | import failchat.Origin.BTTV_CHANNEL 5 | import failchat.Origin.BTTV_GLOBAL 6 | import failchat.Origin.FAILCHAT 7 | import failchat.Origin.FRANKERFASEZ 8 | import failchat.Origin.GOODGAME 9 | import failchat.Origin.TWITCH 10 | import failchat.chat.DeletedMessagePlaceholder 11 | import failchat.chat.Elements 12 | import org.apache.commons.configuration2.Configuration 13 | 14 | class DeletedMessagePlaceholderFactory( 15 | private val emoticonFinder: EmoticonFinder, 16 | private val config: Configuration 17 | ) { 18 | 19 | private val prefixes = mapOf( 20 | "tw" to TWITCH, 21 | "gg" to GOODGAME, 22 | "fc" to FAILCHAT, 23 | "btg" to BTTV_GLOBAL, 24 | "btc" to BTTV_CHANNEL, 25 | "ffz" to FRANKERFASEZ 26 | ) 27 | 28 | fun create(): DeletedMessagePlaceholder { 29 | val text = config.getString(ConfigKeys.deletedMessagePlaceholder) 30 | val emoticons = ArrayList(2) 31 | 32 | val escapedText = text 33 | .let { Elements.escapeBraces(it) } 34 | .let { Elements.escapeLabelCharacters(it) } 35 | 36 | val processedText = SemicolonCodeProcessor.process(escapedText) { code -> 37 | val prefixAndCode = code.split("-", ignoreCase = true, limit = 2) 38 | val origin = prefixes[prefixAndCode.first()] 39 | ?: return@process ReplaceDecision.Skip 40 | 41 | val emoticon = emoticonFinder.findByCode(origin, prefixAndCode[1]) 42 | ?: return@process ReplaceDecision.Skip 43 | 44 | val emoticonNo = emoticons.size 45 | val label = Elements.label(emoticonNo) 46 | emoticons.add(emoticon) 47 | 48 | ReplaceDecision.Replace(label) 49 | } 50 | 51 | return DeletedMessagePlaceholder(processedText, emoticons) 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmojiEmoticon.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import failchat.chat.ImageFormat 5 | 6 | class EmojiEmoticon( 7 | code: String, 8 | override val url: String 9 | ) : Emoticon(Origin.FAILCHAT, code, ImageFormat.VECTOR) 10 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/Emoticon.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import failchat.chat.ImageFormat 5 | import failchat.chat.MessageElement 6 | import java.io.Serializable 7 | 8 | abstract class Emoticon( 9 | val origin: Origin, 10 | val code: String, 11 | val format: ImageFormat 12 | ) : MessageElement, Serializable { 13 | 14 | abstract val url: String 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonAndId.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | class EmoticonAndId(val emoticon: Emoticon, val id: String) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonCodeIdDbStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.collect 6 | import kotlinx.coroutines.runBlocking 7 | import org.mapdb.DB 8 | import org.mapdb.HTreeMap 9 | import org.mapdb.Serializer 10 | import org.mapdb.serializer.GroupSerializer 11 | 12 | class EmoticonCodeIdDbStorage( 13 | db: DB, 14 | override val origin: Origin, 15 | private val caseSensitiveCode: Boolean 16 | ) : OriginEmoticonStorage { 17 | 18 | private val codeToId: HTreeMap 19 | private val idToEmoticon: HTreeMap 20 | 21 | init { 22 | codeToId = db 23 | .hashMap(origin.commonName + "-codeToId", Serializer.STRING, Serializer.STRING) 24 | .createOrOpen() 25 | idToEmoticon = db 26 | .hashMap(origin.commonName + "-idToEmoticon", Serializer.STRING, Serializer.JAVA as GroupSerializer) 27 | .createOrOpen() 28 | } 29 | 30 | override fun findByCode(code: String): Emoticon? { 31 | val cCode = if (caseSensitiveCode) code else code.toLowerCase() 32 | val id = codeToId.get(cCode) ?: return null 33 | return idToEmoticon.get(id) 34 | } 35 | 36 | override fun findById(id: String): Emoticon? { 37 | return idToEmoticon.get(id) 38 | } 39 | 40 | override fun getAll(): Collection { 41 | return idToEmoticon.values.filterNotNull() 42 | } 43 | 44 | override fun count(): Int { 45 | return idToEmoticon.size 46 | } 47 | 48 | override fun putAll(emoticons: Collection) { 49 | emoticons.forEach { 50 | putEmoticon(it) 51 | } 52 | } 53 | 54 | override fun putAll(emoticons: Flow) { 55 | runBlocking { 56 | emoticons.collect { 57 | putEmoticon(it) 58 | } 59 | } 60 | } 61 | 62 | private fun putEmoticon(emoticonAndId: EmoticonAndId) { 63 | val code = emoticonAndId.emoticon.code.let { c -> 64 | if (caseSensitiveCode) c else c.toLowerCase() 65 | } 66 | 67 | idToEmoticon.put(emoticonAndId.id, emoticonAndId.emoticon) 68 | codeToId.put(code, emoticonAndId.id) 69 | } 70 | 71 | override fun clear() { 72 | idToEmoticon.clear() 73 | codeToId.clear() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonCodeIdMemoryStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.collect 6 | import kotlinx.coroutines.runBlocking 7 | import java.util.concurrent.ConcurrentHashMap 8 | 9 | class EmoticonCodeIdMemoryStorage(override val origin: Origin) : OriginEmoticonStorage { 10 | 11 | private val idMap: MutableMap = ConcurrentHashMap() 12 | private val codeMap: MutableMap = ConcurrentHashMap() 13 | 14 | override fun findByCode(code: String): Emoticon? { 15 | return codeMap.get(code) 16 | } 17 | 18 | override fun findById(id: String): Emoticon? { 19 | return idMap.get(id) 20 | } 21 | 22 | override fun getAll(): Collection { 23 | return idMap.values 24 | } 25 | 26 | override fun count(): Int { 27 | return idMap.size 28 | } 29 | 30 | override fun putAll(emoticons: Collection) { 31 | emoticons.forEach { 32 | putEmoticon(it) 33 | } 34 | } 35 | 36 | override fun putAll(emoticons: Flow) { 37 | runBlocking { 38 | emoticons.collect { 39 | putEmoticon(it) 40 | } 41 | } 42 | } 43 | 44 | private fun putEmoticon(emoticonAndId: EmoticonAndId) { 45 | idMap.put(emoticonAndId.id, emoticonAndId.emoticon) 46 | codeMap.put(emoticonAndId.emoticon.code, emoticonAndId.emoticon) 47 | } 48 | 49 | override fun clear() { 50 | idMap.clear() 51 | codeMap.clear() 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonCodeMemoryStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import kotlinx.coroutines.flow.Flow 5 | import kotlinx.coroutines.flow.collect 6 | import kotlinx.coroutines.runBlocking 7 | import java.util.concurrent.ConcurrentHashMap 8 | 9 | class EmoticonCodeMemoryStorage( 10 | override val origin: Origin, 11 | private val caseSensitiveCode: Boolean 12 | ) : OriginEmoticonStorage { 13 | 14 | private val codeMap: MutableMap = ConcurrentHashMap() 15 | 16 | override fun findByCode(code: String): Emoticon? { 17 | val cCode = if (caseSensitiveCode) code else code.toLowerCase() 18 | return codeMap.get(cCode) 19 | } 20 | 21 | override fun findById(id: String): Emoticon? { 22 | val cId = if (caseSensitiveCode) id else id.toLowerCase() 23 | return codeMap.get(cId) 24 | } 25 | 26 | override fun getAll(): Collection { 27 | return codeMap.values 28 | } 29 | 30 | override fun count(): Int { 31 | return codeMap.size 32 | } 33 | 34 | override fun putAll(emoticons: Collection) { 35 | emoticons.forEach { 36 | putEmoticon(it) 37 | } 38 | } 39 | 40 | override fun putAll(emoticons: Flow) { 41 | runBlocking { 42 | emoticons.collect { 43 | putEmoticon(it) 44 | } 45 | } 46 | } 47 | 48 | private fun putEmoticon(emoticonAndId: EmoticonAndId) { 49 | val code = emoticonAndId.emoticon.code.let { 50 | if (caseSensitiveCode) it else it.toLowerCase() 51 | } 52 | 53 | codeMap.put(code, emoticonAndId.emoticon) 54 | } 55 | 56 | override fun clear() { 57 | codeMap.clear() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonFactory.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | interface EmoticonFactory { 4 | fun create(id: String, code: String): Emoticon 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonFinder.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | 5 | interface EmoticonFinder { 6 | 7 | /** 8 | * Find emoticon by code. 9 | * @param code case sensitive code of [Emoticon]. 10 | * */ 11 | fun findByCode(origin: Origin, code: String): Emoticon? 12 | 13 | //todo make id Any? 14 | fun findById(origin: Origin, id: String): Emoticon? 15 | 16 | fun getAll(origin: Origin): Collection 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonIdAndCode.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | data class EmoticonIdAndCode(val id: String, val code: String) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonIdExtractor.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | 5 | interface EmoticonIdExtractor { 6 | val origin: Origin 7 | fun extractId(emoticon: T): String 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonLoadConfiguration.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | 5 | interface EmoticonLoadConfiguration { 6 | 7 | val origin: Origin 8 | 9 | val loader: EmoticonLoader 10 | 11 | val idExtractor: EmoticonIdExtractor 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonLoadException.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | class EmoticonLoadException: Exception { 4 | constructor() : super() 5 | constructor(message: String?) : super(message) 6 | constructor(message: String?, cause: Throwable?) : super(message, cause) 7 | constructor(cause: Throwable?) : super(cause) 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonLoader.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import java.util.concurrent.CompletableFuture 5 | 6 | interface EmoticonLoader { 7 | val origin: Origin 8 | fun loadEmoticons(): CompletableFuture> 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonManager.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import mu.KotlinLogging 4 | 5 | class EmoticonManager( 6 | private val storage: EmoticonStorage 7 | ) { 8 | 9 | private companion object { 10 | val logger = KotlinLogging.logger {} 11 | } 12 | 13 | /** 14 | * Load emoticons by the specified configurations and put them into the storage. Blocking call 15 | * Never throws [Exception]. 16 | * */ 17 | fun actualizeEmoticons(loadConfigurations: List>) { 18 | loadConfigurations.forEach { 19 | try { 20 | actualizeEmoticons(it) 21 | } catch (e: Exception) { 22 | logger.warn("Exception during loading emoticons for {}", it.origin, e) 23 | } 24 | } 25 | } 26 | 27 | /** 28 | * Load emoticons by specified configuration and put them into the storage. Blocking call 29 | * */ 30 | private fun actualizeEmoticons(loadConfiguration: EmoticonLoadConfiguration) { 31 | val origin = loadConfiguration.origin 32 | val emoticonsInStorage = storage.getCount(origin) 33 | 34 | val loadResult = loadEmoticons(loadConfiguration) 35 | 36 | when (loadResult) { 37 | is LoadResult.Failure -> { 38 | logger.warn {"Failed to load emoticon list for $origin. Outdated list will be used, count: $emoticonsInStorage" } 39 | } 40 | is LoadResult.Success -> { 41 | logger.info { "Emoticon list loaded for $origin, count: ${loadResult.emoticonsLoaded}" } 42 | } 43 | } 44 | } 45 | 46 | private fun loadEmoticons(loadConfiguration: EmoticonLoadConfiguration): LoadResult { 47 | val origin = loadConfiguration.origin 48 | 49 | val emoticons = try { 50 | loadConfiguration.loader.loadEmoticons().join() 51 | } catch (e: Exception) { 52 | logger.warn(e) { "Failed to load emoticon list for $origin via bulk loader ${loadConfiguration.loader}" } 53 | return LoadResult.Failure 54 | } 55 | 56 | // Put data in storage 57 | val emoticonAndIdMapping = emoticons 58 | .map { EmoticonAndId(it, loadConfiguration.idExtractor.extractId(it)) } 59 | storage.clear(origin) 60 | storage.putMapping(origin, emoticonAndIdMapping) 61 | 62 | return LoadResult.Success(emoticons.size) 63 | } 64 | 65 | private sealed class LoadResult { 66 | class Success(val emoticonsLoaded: Int) : LoadResult() 67 | object Failure : LoadResult() 68 | } 69 | 70 | } 71 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmoticonStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import failchat.util.enumMap 5 | import kotlinx.coroutines.flow.Flow 6 | import mu.KotlinLogging 7 | 8 | class EmoticonStorage : EmoticonFinder { 9 | 10 | private companion object { 11 | val logger = KotlinLogging.logger {} 12 | } 13 | 14 | private var originStorages: Map = Origin.values 15 | .map { it to EmptyEmoticonStorage(it) } 16 | .toMap(enumMap()) 17 | 18 | fun setStorages(storages: List) { 19 | originStorages = Origin.values.minus(storages.map { it.origin }) 20 | .map { EmptyEmoticonStorage(it) } 21 | .plus(storages) 22 | .map { it.origin to it } 23 | .toMap(enumMap()) 24 | } 25 | 26 | override fun findByCode(origin: Origin, code: String): Emoticon? { 27 | return originStorages 28 | .get(origin)!! 29 | .findByCode(code) 30 | } 31 | 32 | override fun findById(origin: Origin, id: String): Emoticon? { 33 | return originStorages 34 | .get(origin)!! 35 | .findById(id) 36 | } 37 | 38 | override fun getAll(origin: Origin): Collection { 39 | return originStorages 40 | .get(origin)!! 41 | .getAll() 42 | } 43 | 44 | fun getCount(origin: Origin): Int { 45 | return originStorages.get(origin)!!.count() 46 | } 47 | 48 | fun putMapping(origin: Origin, emoticons: Collection) { 49 | originStorages.get(origin)!! 50 | .putAll(emoticons) 51 | } 52 | 53 | fun putChannel(origin: Origin, emoticons: Flow) { 54 | originStorages.get(origin)!!.putAll(emoticons) 55 | } 56 | 57 | fun clear(origin: Origin) { 58 | originStorages.get(origin)!!.clear() 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/EmptyEmoticonStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import kotlinx.coroutines.flow.Flow 5 | import mu.KotlinLogging 6 | 7 | class EmptyEmoticonStorage(override val origin: Origin) : OriginEmoticonStorage { 8 | 9 | private companion object { 10 | val logger = KotlinLogging.logger {} 11 | } 12 | 13 | override fun findByCode(code: String): Emoticon? = null 14 | override fun findById(id: String): Emoticon? = null 15 | override fun getAll(): Collection = emptyList() 16 | override fun count(): Int = 0 17 | override fun putAll(emoticons: Collection) = warnOnPut() 18 | override fun putAll(emoticons: Flow) = warnOnPut() 19 | override fun clear() {} 20 | private fun warnOnPut() { 21 | logger.warn("Put operation in not supported by EmptyEmoticonStorage. origin: {}", origin) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/FailchatEmoticon.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin.FAILCHAT 4 | import failchat.chat.ImageFormat 5 | 6 | class FailchatEmoticon( 7 | code: String, 8 | format: ImageFormat, 9 | override val url: String 10 | ) : Emoticon(FAILCHAT, code, format) 11 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/FailchatEmoticonScanner.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.chat.ImageFormat.RASTER 4 | import failchat.chat.ImageFormat.VECTOR 5 | import failchat.util.filterNotNull 6 | import failchat.util.withSuffix 7 | import mu.KotlinLogging 8 | import java.nio.file.Files 9 | import java.nio.file.Path 10 | import java.time.Duration 11 | import java.time.Instant 12 | import java.util.regex.Pattern 13 | import java.util.stream.Collectors 14 | 15 | class FailchatEmoticonScanner( 16 | private val emoticonsDirectory: Path, 17 | locationUrlPrefix: String 18 | ) { 19 | 20 | private val locationUrlPrefix = locationUrlPrefix.withSuffix("/") 21 | 22 | private companion object { 23 | val logger = KotlinLogging.logger {} 24 | val fileNamePattern: Pattern = Pattern.compile("""(?.+)\.(?jpe?g|png|gif|svg)$""", Pattern.CASE_INSENSITIVE) 25 | } 26 | 27 | fun scan(): List { 28 | val t1 = Instant.now() 29 | val emoticons = Files.list(emoticonsDirectory) 30 | .map { it.fileName.toString() } 31 | .map { fileName -> 32 | val m = fileNamePattern.matcher(fileName) 33 | if (!m.matches()) { 34 | logger.warn("Incorrect failchat emoticon file was ignored: '{}'", fileName) 35 | return@map null 36 | } 37 | 38 | Triple(fileName, m.group("code"), m.group("format")) 39 | } 40 | .filterNotNull() 41 | .map { (fileName, code, formatStr) -> 42 | val format = when (formatStr.toLowerCase()) { 43 | "svg" -> VECTOR 44 | else -> RASTER 45 | } 46 | FailchatEmoticon(code, format, locationUrlPrefix + fileName) 47 | } 48 | .collect(Collectors.toList()) 49 | 50 | val t2 = Instant.now() 51 | logger.debug { "Failchat emoticons was scanned in ${Duration.between(t1, t2).toMillis()} ms" } 52 | 53 | return emoticons 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/FailchatEmoticonUpdater.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin.FAILCHAT 4 | 5 | class FailchatEmoticonUpdater( 6 | private val storage: EmoticonStorage, 7 | private val scanner: FailchatEmoticonScanner 8 | ) { 9 | 10 | fun update() { 11 | val emoticons = scanner.scan() 12 | .map { EmoticonAndId(it, it.code) } 13 | 14 | storage.clear(FAILCHAT) 15 | storage.putMapping(FAILCHAT, emoticons) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/GlobalEmoticonUpdater.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.gui.GuiEventHandler 4 | import failchat.resetEmoticonsUpdatedTime 5 | import failchat.util.executeWithCatch 6 | import mu.KotlinLogging 7 | import org.apache.commons.configuration2.Configuration 8 | import java.util.concurrent.ExecutorService 9 | 10 | class GlobalEmoticonUpdater( 11 | private val emoticonManager: EmoticonManager, 12 | private val emoticonLoadConfigurations: List>, 13 | private val backgroundExecutor: ExecutorService, 14 | private val guiEventHandler: GuiEventHandler, 15 | private val config: Configuration 16 | ) { 17 | 18 | private companion object { 19 | val logger = KotlinLogging.logger {} 20 | } 21 | 22 | fun reloadEmoticonsAsync() { 23 | config.resetEmoticonsUpdatedTime() 24 | actualizeEmoticonsAsync() 25 | } 26 | 27 | fun actualizeEmoticonsAsync() { 28 | backgroundExecutor.executeWithCatch { 29 | guiEventHandler.notifyEmoticonsAreLoading() 30 | 31 | emoticonManager.actualizeEmoticons(emoticonLoadConfigurations) 32 | 33 | guiEventHandler.notifyEmoticonsLoaded() 34 | } 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/MapdbFactory.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import mu.KotlinLogging 4 | import org.mapdb.DB 5 | import org.mapdb.DBMaker 6 | import java.nio.file.Files 7 | import java.nio.file.Path 8 | 9 | object MapdbFactory { 10 | 11 | private val logger = KotlinLogging.logger {} 12 | 13 | fun create(dbPath: Path): DB { 14 | Files.createDirectories(dbPath.parent) 15 | 16 | return DBMaker 17 | .fileDB(dbPath.toFile()) 18 | .checksumHeaderBypass() 19 | .fileMmapEnable() 20 | .make() 21 | .also { 22 | logger.info("DB was initialized at '{}'", dbPath) 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/OriginEmoticonStorage.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | interface OriginEmoticonStorage { 7 | 8 | val origin: Origin 9 | 10 | fun findByCode(code: String): Emoticon? 11 | 12 | fun findById(id: String): Emoticon? 13 | 14 | fun getAll(): Collection 15 | 16 | fun count(): Int 17 | 18 | fun putAll(emoticons: Collection) 19 | 20 | /** Put all emoticons in storage from the channel. Blocking call. */ 21 | fun putAll(emoticons: Flow) 22 | 23 | fun clear() 24 | } 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/OriginEmoticonStorageFactory.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import failchat.Origin 4 | import failchat.Origin.BTTV_CHANNEL 5 | import failchat.Origin.BTTV_GLOBAL 6 | import failchat.Origin.FAILCHAT 7 | import failchat.Origin.FRANKERFASEZ 8 | import failchat.Origin.GOODGAME 9 | import failchat.Origin.SEVEN_TV_CHANNEL 10 | import failchat.Origin.SEVEN_TV_GLOBAL 11 | import failchat.Origin.TWITCH 12 | import org.mapdb.DB 13 | 14 | object OriginEmoticonStorageFactory { 15 | 16 | //todo code db origin storage, BTTV_GLOBAL 17 | 18 | private val caseSensitiveOptions = mapOf( 19 | TWITCH to false, 20 | GOODGAME to false, 21 | FAILCHAT to false, 22 | BTTV_GLOBAL to true, 23 | BTTV_CHANNEL to true, 24 | FRANKERFASEZ to true, 25 | SEVEN_TV_GLOBAL to true, 26 | SEVEN_TV_CHANNEL to true 27 | ) 28 | 29 | private val idCodeDbOrigins: List = listOf(BTTV_GLOBAL, SEVEN_TV_GLOBAL, GOODGAME) 30 | private val codeMemoryOrigins: List = listOf(BTTV_CHANNEL, SEVEN_TV_CHANNEL, FRANKERFASEZ, FAILCHAT) 31 | 32 | val dbOrigins: List = idCodeDbOrigins + TWITCH 33 | 34 | fun create(db: DB, twitchEmoticonFactory: TwitchEmoticonFactory): List { 35 | return idCodeDbOrigins.map { EmoticonCodeIdDbStorage(db, it, caseSensitiveOptions[it]!!) } + 36 | codeMemoryOrigins.map { EmoticonCodeMemoryStorage(it, caseSensitiveOptions[it]!!) } + 37 | EmoticonCodeIdDbCompactStorage(db, TWITCH, twitchEmoticonFactory) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/ReplaceDecision.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | sealed class ReplaceDecision { 4 | object Skip : ReplaceDecision() 5 | class Replace(val replacement: String) : ReplaceDecision() 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/emoticon/SemicolonCodeProcessor.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | import java.util.regex.Pattern 4 | 5 | object SemicolonCodeProcessor { 6 | 7 | private val emoticonCodePattern: Pattern = Pattern.compile("""(?
:(?[\w-]+)):""")
 8 | 
 9 |     fun process(initialString: String, decisionMaker: (code: String) -> ReplaceDecision): String {
10 |         // Can't use Matcher.appendReplacement() because it resets position when Matcher.find(start) invoked
11 |         val matcher = emoticonCodePattern.matcher(initialString)
12 |         val sb = lazy(LazyThreadSafetyMode.NONE) { StringBuilder() }
13 |         var cursor = 0
14 | 
15 |         while (matcher.find(cursor)) {
16 |             val code = matcher.group("code")
17 |             val decision = decisionMaker.invoke(code)
18 | 
19 |             val end = matcher.end()
20 |             when (decision) {
21 |                 is ReplaceDecision.Replace -> {
22 |                     val appendFrom = if (sb.isInitialized()) cursor else 0
23 |                     sb.value.append(initialString, appendFrom, matcher.start())
24 |                     sb.value.append(decision.replacement)
25 |                     cursor = end
26 |                 }
27 |                 is ReplaceDecision.Skip -> {
28 |                     val lastSemicolonPosition = if (end > 0) end - 1 else end
29 |                     if (sb.isInitialized()) {
30 |                         sb.value.append(initialString, cursor, lastSemicolonPosition)
31 |                     }
32 |                     cursor = lastSemicolonPosition
33 |                 }
34 |             }
35 |         }
36 | 
37 |         if (!sb.isInitialized()) return initialString
38 | 
39 |         sb.value.append(initialString, cursor, initialString.length)
40 |         return sb.value.toString()
41 |     }
42 | 
43 | }
44 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/TwitchEmoticonFactory.kt:
--------------------------------------------------------------------------------
 1 | package failchat.emoticon
 2 | 
 3 | import failchat.twitch.TwitchEmoticon
 4 | 
 5 | class TwitchEmoticonFactory : EmoticonFactory {
 6 |     override fun create(id: String, code: String): Emoticon {
 7 |         return TwitchEmoticon(id, code)
 8 |     }
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/WordReplacer.kt:
--------------------------------------------------------------------------------
 1 | package failchat.emoticon
 2 | 
 3 | import java.util.regex.Pattern
 4 | 
 5 | object WordReplacer {
 6 | 
 7 |     val wordPattern: Pattern = Pattern.compile("""(?<=\s|^)(.+?)(?=\s|$)""")
 8 | 
 9 |     inline fun replace(initialString: String, decisionMaker: (word: String) -> ReplaceDecision): String {
10 |         // Can't use Matcher.appendReplacement() because it resets position when Matcher.find(start) invoked
11 |         val matcher = wordPattern.matcher(initialString)
12 |         val sb = lazy(LazyThreadSafetyMode.NONE) { StringBuilder() }
13 |         var cursor = 0
14 | 
15 |         while (matcher.find(cursor)) {
16 |             val code = matcher.group(1)
17 |             val decision = decisionMaker.invoke(code)
18 | 
19 |             val end = matcher.end()
20 |             when (decision) {
21 |                 is ReplaceDecision.Replace -> {
22 |                     val appendFrom = if (sb.isInitialized()) cursor else 0
23 |                     sb.value.append(initialString, appendFrom, matcher.start())
24 |                     sb.value.append(decision.replacement)
25 |                 }
26 |                 is ReplaceDecision.Skip -> {
27 |                     if (sb.isInitialized()) {
28 |                         sb.value.append(initialString, cursor, end)
29 |                     }
30 |                 }
31 |             }
32 |             cursor = end
33 |         }
34 | 
35 |         if (!sb.isInitialized()) return initialString
36 | 
37 |         sb.value.append(initialString, cursor, initialString.length)
38 |         return sb.toString()
39 |     }
40 | 
41 | }
42 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/ChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | class ChannelNotFoundException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/ChannelOfflineException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | import failchat.Origin
4 | 
5 | class ChannelOfflineException(
6 |         val origin: Origin,
7 |         val channel: String
8 | ) : Exception("origin: $origin, channel: $channel")
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/DataNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | open class DataNotFoundException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/InvalidConfigurationException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | class InvalidConfigurationException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/NativeCallException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | class NativeCallException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedResponseCodeException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | class UnexpectedResponseCodeException : UnexpectedResponseException{
4 |     constructor(code: Int) : super("code: '$code'")
5 |     constructor(code: Int, url: String) : super("code: '$code', url: '$url'")
6 | }
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedResponseException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | open class UnexpectedResponseException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedWsMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 | 
3 | class UnexpectedWsMessage : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/GithubClient.kt:
--------------------------------------------------------------------------------
 1 | package failchat.github
 2 | 
 3 | import com.fasterxml.jackson.databind.JsonNode
 4 | import com.fasterxml.jackson.databind.ObjectMapper
 5 | import failchat.exception.UnexpectedResponseCodeException
 6 | import failchat.exception.UnexpectedResponseException
 7 | import failchat.util.thenUse
 8 | import failchat.util.toFuture
 9 | import mu.KotlinLogging
10 | import okhttp3.OkHttpClient
11 | import okhttp3.Request
12 | import java.util.concurrent.CompletableFuture
13 | 
14 | class GithubClient(
15 |         private val apiUrl: String,
16 |         private val httpClient: OkHttpClient,
17 |         private val objectMapper: ObjectMapper
18 | ) {
19 | 
20 |     private companion object {
21 |         val logger = KotlinLogging.logger {}
22 |     }
23 | 
24 |     fun requestLatestRelease(userName: String, repository: String): CompletableFuture {
25 |         val request = Request.Builder()
26 |                 .url("${apiUrl.removeSuffix("/")}/repos/$userName/$repository/releases")
27 |                 .get()
28 |                 .build()
29 | 
30 |         return httpClient.newCall(request)
31 |                 .toFuture()
32 |                 .thenUse {
33 |                     if (it.code != 200) throw UnexpectedResponseCodeException(it.code)
34 |                     val responseBody = it.body ?: throw UnexpectedResponseException("null body")
35 |                     val releasesNode = objectMapper.readTree(responseBody.string())
36 |                     findLatestRelease(releasesNode) ?: throw NoReleasesFoundException()
37 |                 }
38 |     }
39 | 
40 |     private fun findLatestRelease(releasesNode: JsonNode): Release? {
41 |         return releasesNode.asSequence()
42 |                 .filter { !it.get("draft").asBoolean() }
43 |                 .filter { !it.get("prerelease").asBoolean() }
44 |                 .filter { !it.get("assets").isEmpty() }
45 |                 .map { parseRelease(it) }
46 |                 .filterNotNull()
47 |                 .firstOrNull()
48 |     }
49 |     
50 |     private fun parseRelease(releaseNode: JsonNode): Release? {
51 |         return try {
52 |             Release(
53 |                     Version.parse(releaseNode.get("tag_name").asText()),
54 |                     releaseNode.get("html_url").asText(),
55 |                     releaseNode.get("assets").get(0).get("browser_download_url").asText()
56 |             )
57 |         } catch (e: Exception) {
58 |             logger.warn("Failed to parse release node, skip it", e)
59 |             null
60 |         }
61 |     }
62 | 
63 | }
64 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/NoReleasesFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 | 
3 | class NoReleasesFoundException : Exception {
4 |     constructor() : super()
5 |     constructor(message: String?) : super(message)
6 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
7 |     constructor(cause: Throwable?) : super(cause)
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/Release.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 | 
3 | class Release(
4 |         val version: Version,
5 |         val releasePageUrl: String,
6 |         val assetDownloadUrl: String
7 | )
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/ReleaseChecker.kt:
--------------------------------------------------------------------------------
 1 | package failchat.github
 2 | 
 3 | import mu.KotlinLogging
 4 | import org.apache.commons.configuration2.Configuration
 5 | 
 6 | class ReleaseChecker(
 7 |         private val githubClient: GithubClient,
 8 |         private val config: Configuration
 9 | ) {
10 | 
11 |     private companion object {
12 |         val logger = KotlinLogging.logger {}
13 |         const val lastNotifiedKey = "release-checker.latest-notified-version"
14 |     }
15 | 
16 |     private val userName: String = config.getString("github.user-name")
17 |     private val repository: String = config.getString("github.repository")
18 | 
19 |     fun checkNewRelease(onNewRelease: (Release) -> Unit) {
20 |         if (!config.getBoolean("release-checker.enabled")) {
21 |             logger.debug("Release check skipped")
22 |             return
23 |         }
24 | 
25 |         githubClient
26 |                 .requestLatestRelease(userName, repository)
27 |                 .thenApply { lastRelease ->
28 |                     val lastNotifiedReleaseVersion = Version.parse(config.getString(lastNotifiedKey))
29 |                     if (lastRelease.version <= lastNotifiedReleaseVersion) {
30 |                         logger.info("Latest version of application installed: '{}'", lastNotifiedReleaseVersion)
31 |                         return@thenApply
32 |                     }
33 | 
34 |                     config.setProperty(lastNotifiedKey, lastRelease.version.toString())
35 |                     logger.info("Notifying about new release with version: '{}'", lastRelease.version)
36 |                     onNewRelease.invoke(lastRelease)
37 |                 }
38 |                 .exceptionally { logger.warn("Exception during check for new release", it) }
39 |     }
40 | 
41 | }
42 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/Version.kt:
--------------------------------------------------------------------------------
 1 | package failchat.github
 2 | 
 3 | import java.util.regex.Pattern
 4 | 
 5 | class Version(val major: Int, val minor: Int, val micro: Int) : Comparable {
 6 | 
 7 |     companion object {
 8 |         val versionPattern: Pattern = Pattern.compile("""v(\d+)\.(\d+)\.(\d+)""")
 9 | 
10 |         fun parse(stringVersion: String): Version {
11 |             val matcher = versionPattern.matcher(stringVersion)
12 |             if (!matcher.matches()) throw IllegalArgumentException("Invalid version format: '$stringVersion'")
13 |             return Version(matcher.group(1).toInt(), matcher.group(2).toInt(), matcher.group(3).toInt())
14 |         }
15 |     }
16 | 
17 |     override fun compareTo(other: Version): Int {
18 |         var comparison: Int = major.compareTo(other.major)
19 |         if (comparison != 0) return comparison
20 | 
21 |         comparison = minor.compareTo(other.minor)
22 |         if (comparison != 0) return comparison
23 | 
24 |         return micro.compareTo(other.micro)
25 |     }
26 | 
27 |     override fun toString() = "v$major.$minor.$micro"
28 | 
29 | }
30 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgApi2Client.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import com.fasterxml.jackson.databind.ObjectMapper
 4 | import failchat.Origin.GOODGAME
 5 | import failchat.exception.ChannelOfflineException
 6 | import failchat.exception.UnexpectedResponseCodeException
 7 | import failchat.exception.UnexpectedResponseException
 8 | import failchat.util.await
 9 | import okhttp3.OkHttpClient
10 | import okhttp3.Request
11 | 
12 | class GgApi2Client(
13 |         private val httpClient: OkHttpClient,
14 |         private val objectMapper: ObjectMapper
15 | ) {
16 | 
17 |     private companion object {
18 |         // Documentation: https://api2.goodgame.ru/apigility/documentation/Goodgame-v2
19 |         const val apiUrl = "https://api2.goodgame.ru/v2"
20 |     }
21 | 
22 |     /** @return viewers count for a goodgame video player. */
23 |     suspend fun requestViewersCount(channelName: String): Int {
24 |         val request = Request.Builder()
25 |                 .get()
26 |                 .url("$apiUrl/streams/$channelName")
27 |                 .header("Accept", "application/vnd.goodgame.v2+json")
28 |                 .build()
29 | 
30 |         val response = httpClient.newCall(request).await().use { response ->
31 |             if (response.code != 200) throw UnexpectedResponseCodeException(response.code)
32 |             val responseBody = response.body ?: throw UnexpectedResponseException("null body")
33 |             objectMapper.readValue(responseBody.charStream(), StreamResponse::class.java)
34 |         }
35 | 
36 |         if (response.status != "Live") {
37 |             throw ChannelOfflineException(GOODGAME, channelName)
38 |         }
39 | 
40 |         return response.playerViewers
41 |     }
42 | 
43 | }
44 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgAuthorColorHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | 
 5 | class GgAuthorColorHandler : MessageHandler {
 6 | 
 7 |     override fun handleMessage(message: GgMessage) {
 8 |         val color = GgColors.byRole[message.authorColorName] ?: return
 9 |         message.author.color = color
10 |     }
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgChannel.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 | 
3 | data class GgChannel(
4 |         val name: String,
5 |         val id: Long,
6 |         val premium: Boolean
7 | )
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgColors.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import javafx.scene.paint.Color
 4 | 
 5 | object GgColors {
 6 |     val defaultColor: Color = Color.web("#73adff")
 7 | 
 8 |     val byRole = mapOf(
 9 |             "bronze" to Color.web("#e7820a"),
10 |             "silver" to Color.web("#b4b4b4"),
11 |             "gold" to Color.web("#eefc08"),
12 |             "diamond" to Color.web("#8781bd"),
13 |             "king" to Color.web("#30d5c8"),
14 |             "top-one" to Color.web("#3bcbff"),
15 |             "premium" to Color.web("#bd70d7"),
16 |             "premium-personal" to Color.web("#31a93a"),
17 |             "moderator" to Color.web("#ec4058"),
18 |             "streamer" to Color.web("#e8bb00"),
19 |             "streamer-helper" to Color.web("#e8bb00")
20 |     )
21 | }
22 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.ImageFormat.RASTER
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class GgEmoticon(
 8 |         code: String,
 9 |         override val url: String,
10 |         val ggId: Long
11 | ) : Emoticon(Origin.GOODGAME, code, RASTER) {
12 | 
13 |     var animatedInstance: GgEmoticon? = null
14 | 
15 | }
16 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin.GOODGAME
 4 | import failchat.chat.MessageHandler
 5 | import failchat.emoticon.EmoticonFinder
 6 | import failchat.emoticon.ReplaceDecision
 7 | import failchat.emoticon.SemicolonCodeProcessor
 8 | 
 9 | class GgEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 | 
11 |     override fun handleMessage(message: GgMessage) {
12 |         message.text = SemicolonCodeProcessor.process(message.text) { code ->
13 |             val emoticon = emoticonFinder.findByCode(GOODGAME, code) as? GgEmoticon
14 |                     ?: return@process ReplaceDecision.Skip
15 | 
16 |             val emoticonToAdd = if (message.authorHasPremium && emoticon.animatedInstance != null) {
17 |                 emoticon.animatedInstance!!
18 |             } else {
19 |                 emoticon
20 |             }
21 | 
22 |             val label = message.addElement(emoticonToAdd)
23 |             return@process ReplaceDecision.Replace(label)
24 |         }
25 |     }
26 | 
27 | }
28 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonIdExtractor
 5 | 
 6 | object GgEmoticonIdExtractor : EmoticonIdExtractor {
 7 | 
 8 |     override val origin = Origin.GOODGAME
 9 | 
10 |     override fun extractId(emoticon: GgEmoticon): String {
11 |         return emoticon.ggId.toString()
12 |     }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoadConfiguration
 5 | 
 6 | class GgEmoticonLoadConfiguration(override val loader: GgEmoticonLoader) : EmoticonLoadConfiguration {
 7 |     override val origin = Origin.GOODGAME
 8 |     override val idExtractor = GgEmoticonIdExtractor
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoader
 5 | import kotlinx.coroutines.CoroutineScope
 6 | import kotlinx.coroutines.Dispatchers
 7 | import kotlinx.coroutines.future.future
 8 | import java.util.concurrent.CompletableFuture
 9 | 
10 | class GgEmoticonLoader(private val ggApiClient: GgApiClient) : EmoticonLoader {
11 | 
12 |     override val origin = Origin.GOODGAME
13 | 
14 |     override fun loadEmoticons(): CompletableFuture> {
15 |         return CoroutineScope(Dispatchers.Default).future {
16 |             ggApiClient.requestEmoticonList()
17 |         }
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgMessage.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.Author
 5 | import failchat.chat.ChatMessage
 6 | 
 7 | class GgMessage(
 8 |         id: Long,
 9 |         val ggId: Long,
10 |         author: String,
11 |         text: String,
12 |         val authorHasPremium: Boolean,
13 |         /** Mapping of channel id to subscription duration. */
14 |         val subscriptionDuration: Map,
15 |         val badgeName: String,
16 |         val authorColorName: String,
17 |         val sponsorLevel: Int,
18 |         val authorRights: Int
19 | ) : ChatMessage(id, Origin.GOODGAME, Author(author, Origin.GOODGAME), text)
20 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgViewersCountLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.Origin.GOODGAME
 4 | import failchat.viewers.ViewersCountLoader
 5 | import kotlinx.coroutines.CoroutineScope
 6 | import kotlinx.coroutines.Dispatchers
 7 | import kotlinx.coroutines.future.future
 8 | import java.util.concurrent.CompletableFuture
 9 | 
10 | class GgViewersCountLoader(
11 |         private val ggApi2Client: GgApi2Client,
12 |         private val channelName: String
13 | ) : ViewersCountLoader {
14 | 
15 |     override val origin = GOODGAME
16 | 
17 |     override fun loadViewersCount(): CompletableFuture {
18 |         return CoroutineScope(Dispatchers.Default).future {
19 |             ggApi2Client.requestViewersCount(channelName)
20 |         }
21 |     }
22 | }
23 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/HtmlUrlCleaner.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | 
 5 | import java.util.regex.Pattern
 6 | 
 7 | class HtmlUrlCleaner : MessageHandler {
 8 | 
 9 |     private val htmlUrlPattern = Pattern.compile("""\1""")
10 | 
11 |     override fun handleMessage(message: GgMessage) {
12 |         var matcher = htmlUrlPattern.matcher(message.text)
13 |         while (matcher.find()) {
14 |             val url = matcher.group(1)
15 |             message.text = matcher.replaceFirst(url)
16 |             matcher = htmlUrlPattern.matcher(message.text)
17 |         }
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/StreamResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import com.fasterxml.jackson.annotation.JsonProperty
 4 | 
 5 | data class StreamResponse(
 6 |         val status: String,
 7 |         @JsonProperty("player_viewers")
 8 |         val playerViewers: Int
 9 | )
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ChatFrameLauncher.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import failchat.Dependencies
 4 | import failchat.platform.windows.WindowsCtConfigurator
 5 | import failchat.util.LateinitVal
 6 | import javafx.application.Application
 7 | import javafx.stage.Stage
 8 | 
 9 | class ChatFrameLauncher : Application() {
10 | 
11 |     companion object {
12 |         val deps = LateinitVal()
13 |     }
14 | 
15 |     override fun start(primaryStage: Stage) {
16 |         //todo remove copypaste
17 |         val config = deps.get()!!.configuration
18 | 
19 |         val isWindows = com.sun.jna.Platform.isWindows()
20 |         val ctConfigurator: ClickTransparencyConfigurator? = if (isWindows) {
21 |             WindowsCtConfigurator(config)
22 |         } else {
23 |             null
24 |         }
25 | 
26 |         val chat = ChatFrame(
27 |                 this,
28 |                 config,
29 |                 deps.get()!!.skinList,
30 |                 lazy { deps.get()!!.guiEventHandler },
31 |                 ctConfigurator
32 |         )
33 | 
34 |         // init web engine (fixes flickering)
35 |         chat.clearWebContent()
36 | 
37 |         chat.show()
38 |     }
39 | 
40 | }
41 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ChatGuiEventHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import failchat.AppStateManager
 4 | import failchat.chat.ChatMessageSender
 5 | import failchat.util.executeWithCatch
 6 | import java.util.concurrent.Executors
 7 | 
 8 | class ChatGuiEventHandler(
 9 |         private val appStateManager: AppStateManager,
10 |         private val messageSender: ChatMessageSender
11 | ) : GuiEventHandler {
12 | 
13 |     private val executor = Executors.newSingleThreadExecutor()
14 | 
15 |     override fun handleStartChat() {
16 |     }
17 | 
18 |     override fun handleStopChat() {
19 |         executor.executeWithCatch {
20 |             appStateManager.shutDown(true)
21 |         }
22 |     }
23 | 
24 |     override fun handleShutDown() {
25 |         executor.executeWithCatch {
26 |             appStateManager.shutDown(true)
27 |         }
28 |     }
29 | 
30 |     override fun handleResetUserConfiguration() {
31 |     }
32 | 
33 |     override fun handleConfigurationChange() {
34 |         executor.executeWithCatch {
35 |             messageSender.sendClientConfiguration()
36 |         }
37 |     }
38 | 
39 |     override fun handleClearChat() {
40 |         executor.executeWithCatch {
41 |             messageSender.sendClearChat()
42 |         }
43 |     }
44 | 
45 |     override fun notifyEmoticonsAreLoading() {
46 |     }
47 | 
48 |     override fun notifyEmoticonsLoaded() {
49 |     }
50 | 
51 | }
52 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ClickTransparencyConfigurator.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import javafx.stage.Stage
 4 | 
 5 | interface ClickTransparencyConfigurator {
 6 | 
 7 |     fun configureClickTransparency(stage: Stage)
 8 | 
 9 |     fun removeClickTransparency(stage: Stage)
10 | }
11 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/Extensions.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import javafx.scene.control.TextField
 4 | 
 5 | fun TextField.configureChannelField(editable: Boolean) {
 6 |     if (editable) {
 7 |         style = ""
 8 |         isEditable = true
 9 |     } else {
10 |         style = "-fx-background-color: lightgrey"
11 |         isEditable = false
12 |     }
13 | }


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiEventHandlerIf.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | interface GuiEventHandler {
 4 | 
 5 |     fun handleStartChat()
 6 | 
 7 |     fun handleStopChat()
 8 | 
 9 |     fun handleShutDown()
10 | 
11 |     fun handleResetUserConfiguration()
12 | 
13 |     fun handleConfigurationChange()
14 | 
15 |     fun handleClearChat()
16 | 
17 |     fun notifyEmoticonsAreLoading()
18 | 
19 |     fun notifyEmoticonsLoaded()
20 | 
21 | }
22 | 
23 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiFrames.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 | 
3 | class GuiFrames(
4 |         val settingsFrame: SettingsFrame,
5 |         val chatFrame: ChatFrame
6 | )
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiMode.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 | 
3 | enum class GuiMode {
4 |     NO_GUI,
5 |     CHAT_ONLY,
6 |     FULL_GUI
7 | }
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/Images.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 | 
3 | import javafx.scene.image.Image
4 | 
5 | object Images {
6 |     val appIcon = Image(Images::class.java.getResourceAsStream("/icons/failchat.png"))
7 | }
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/PortBindAlert.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import failchat.FailchatServerInfo
 4 | import javafx.application.Application
 5 | import javafx.scene.control.Alert
 6 | import javafx.scene.control.Alert.AlertType
 7 | import javafx.stage.Stage
 8 | 
 9 | class PortBindAlert : Application() {
10 | 
11 |     override fun start(primaryStage: Stage) {
12 |         val alert = Alert(AlertType.ERROR)
13 | 
14 |         alert.title = "Launch error"
15 |         alert.headerText = "Looks like failchat is already running."
16 |         alert.contentText = "Failed to create socket at ${FailchatServerInfo.host.hostAddress}:${FailchatServerInfo.port}"
17 | 
18 |         val stage = alert.dialogPane.scene.window as Stage
19 |         stage.icons.setAll(Images.appIcon)
20 | 
21 |         alert.showAndWait()
22 |     }
23 | }
24 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/SkinConverter.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import failchat.skin.Skin
 4 | import javafx.util.StringConverter
 5 | import mu.KotlinLogging
 6 | 
 7 | class SkinConverter(skins: List) : StringConverter() {
 8 | 
 9 |     private companion object {
10 |         val logger = KotlinLogging.logger {}
11 |     }
12 | 
13 |     private val skinMap: Map = skins.map { it.name to it }.toMap()
14 |     private val defaultSkin: Skin = skins.first()
15 | 
16 |     override fun toString(skin: Skin): String {
17 |         return skin.name
18 |     }
19 | 
20 |     override fun fromString(skinName: String): Skin {
21 |         return skinMap.get(skinName)
22 |                 ?: run {
23 |                     logger.warn { "Unknown skin '$skinName', default skin '${defaultSkin.name}' will be used" }
24 |                     defaultSkin
25 |                 }
26 |     }
27 | 
28 | }
29 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/WebViewLogger.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import org.slf4j.Logger
 4 | import org.slf4j.LoggerFactory
 5 | 
 6 | object WebViewLogger {
 7 | 
 8 |     private val logger: Logger = LoggerFactory.getLogger(WebViewLogger::class.java)
 9 | 
10 |     fun log(text: String) {
11 |         logger.debug(text)
12 |     }
13 | 
14 |     fun error(text: String) {
15 |         logger.warn(text)
16 |     }
17 | 
18 | }
19 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/platform/windows/WindowsCtConfigurator.kt:
--------------------------------------------------------------------------------
 1 | package failchat.platform.windows
 2 | 
 3 | import com.sun.jna.platform.win32.WinDef.HWND
 4 | import failchat.ConfigKeys
 5 | import failchat.gui.ClickTransparencyConfigurator
 6 | import javafx.stage.Stage
 7 | import javafx.stage.StageStyle.DECORATED
 8 | import javafx.stage.StageStyle.TRANSPARENT
 9 | import mu.KotlinLogging
10 | import org.apache.commons.configuration2.Configuration
11 | 
12 | class WindowsCtConfigurator(private val config: Configuration) : ClickTransparencyConfigurator {
13 | 
14 |     private companion object {
15 |         val logger = KotlinLogging.logger {}
16 |     }
17 | 
18 |     override fun configureClickTransparency(stage: Stage) {
19 |         if (!config.getBoolean(ConfigKeys.clickTransparency)) return
20 | 
21 |         val handle = getWindowHandle(stage) ?: return
22 | 
23 |         try {
24 |             Windows.makeWindowClickTransparent(handle)
25 |         } catch (t: Throwable) {
26 |             logger.error("Failed to make clicks transparent for {} frame", stage.style, t)
27 |         }
28 |     }
29 | 
30 |     override fun removeClickTransparency(stage: Stage) {
31 |         val handle = getWindowHandle(stage) ?: return
32 | 
33 |         try {
34 |             val removeLayeredStyle = when (stage.style) {
35 |                 DECORATED -> true
36 |                 TRANSPARENT -> false
37 |                 else -> throw IllegalArgumentException("StageStyle: ${stage.style}")
38 |             }
39 | 
40 |             Windows.makeWindowClickOpaque(handle, removeLayeredStyle)
41 |         } catch (t: Throwable) {
42 |             logger.error("Failed to make clicks opaque for {} frame", stage.style, t)
43 |         }
44 |     }
45 | 
46 |     private fun getWindowHandle(stage: Stage): HWND? {
47 |         return try {
48 |             Windows.getWindowHandle(stage)
49 |         } catch (t: Throwable) {
50 |             logger.error("Failed to get handle for {} window", stage.style, t)
51 |             null
52 |         }
53 |     }
54 | }
55 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/Skin.kt:
--------------------------------------------------------------------------------
1 | package failchat.skin
2 | 
3 | import java.nio.file.Path
4 | 
5 | class Skin(
6 |         val name: String,
7 |         val htmlPath: Path
8 | )
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/SkinScanner.kt:
--------------------------------------------------------------------------------
 1 | package failchat.skin
 2 | 
 3 | import java.nio.file.Files
 4 | import java.nio.file.Path
 5 | 
 6 | class SkinScanner(workDirectory: Path) {
 7 | 
 8 |     private val skinsDirectory: Path = workDirectory.resolve("skins")
 9 | 
10 |     fun scan(): List {
11 |         Files.newDirectoryStream(skinsDirectory).use { stream ->
12 |             return stream
13 |                     .filterNotNull()
14 |                     .filterNot { it.fileName.toString().startsWith("_") } //ignore _shared directory
15 |                     .map {
16 |                         val skinName = it.fileName.toString()
17 |                         Skin(skinName, resolveSkinPath(skinName))
18 |                     }
19 |                     .filter { Files.exists(it.htmlPath) }
20 |         }
21 |     }
22 | 
23 |     private fun resolveSkinPath(skinName: String): Path {
24 |         return skinsDirectory.resolve(skinName).resolve("$skinName.html")
25 |     }
26 | 
27 | }
28 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/Skins.kt:
--------------------------------------------------------------------------------
1 | package failchat.skin
2 | 
3 | object Skins {
4 |     const val default = "old_sc2tv"
5 | }
6 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/AuthResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import com.fasterxml.jackson.annotation.JsonProperty
 4 | 
 5 | data class AuthResponse(
 6 |         @JsonProperty("access_token")
 7 |         val accessToken: String,
 8 |         @JsonProperty("expires_in")
 9 |         val expiresIn: Long
10 | )
11 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BadgesResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import com.fasterxml.jackson.annotation.JsonProperty
 4 | 
 5 | data class BadgesResponse(
 6 |         val data: List
 7 | ) {
 8 | 
 9 |     data class Data(
10 |             @JsonProperty("set_id")
11 |             val setId: String,
12 |             val versions: List
13 |     )
14 | 
15 |     data class Version(
16 |             val id: String,
17 |             @JsonProperty("image_url_1x")
18 |             val imageUrl1x: String,
19 |             val description: String
20 |     )
21 | }
22 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | class BttvChannelNotFoundException(val channel: String) : Exception()
4 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.ImageFormat.RASTER
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class BttvEmoticon(
 8 |         origin: Origin,
 9 |         code: String,
10 |         override val url: String
11 | ) : Emoticon(origin, code, RASTER)
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticonHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.MessageHandler
 5 | import failchat.emoticon.EmoticonFinder
 6 | import failchat.emoticon.ReplaceDecision
 7 | import failchat.emoticon.WordReplacer
 8 | 
 9 | class BttvEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 | 
11 |     override fun handleMessage(message: TwitchMessage) {
12 |         handleEmoticons(message, Origin.BTTV_GLOBAL)
13 |         handleEmoticons(message, Origin.BTTV_CHANNEL)
14 |     }
15 | 
16 |     private fun handleEmoticons(message: TwitchMessage, origin: Origin) {
17 |         message.text = WordReplacer.replace(message.text) { code ->
18 |             val emoticon = emoticonFinder.findByCode(origin, code)
19 |                     ?: return@replace ReplaceDecision.Skip
20 |             val label = message.addElement(emoticon)
21 |             return@replace ReplaceDecision.Replace(label)
22 |         }
23 |     }
24 | 
25 | }
26 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonIdExtractor
 5 | 
 6 | object BttvEmoticonIdExtractor : EmoticonIdExtractor {
 7 | 
 8 |     override val origin = Origin.BTTV_GLOBAL
 9 | 
10 |     override fun extractId(emoticon: BttvEmoticon): String {
11 |         return emoticon.code
12 |     }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvGlobalEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoadConfiguration
 5 | 
 6 | class BttvGlobalEmoticonLoadConfiguration(
 7 |         override val loader: BttvGlobalEmoticonLoader
 8 | ) : EmoticonLoadConfiguration {
 9 |     override val origin = Origin.BTTV_GLOBAL
10 |     override val idExtractor = BttvEmoticonIdExtractor
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoader
 5 | import java.util.concurrent.CompletableFuture
 6 | 
 7 | class BttvGlobalEmoticonLoader(private val bttvApiClient: BttvApiClient) : EmoticonLoader {
 8 | 
 9 |     override val origin = Origin.BTTV_GLOBAL
10 | 
11 |     override fun loadEmoticons(): CompletableFuture> {
12 |         return bttvApiClient.loadGlobalEmoticons()
13 |     }
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/ConfigurationTokenContainer.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.ConfigKeys
 4 | import mu.KotlinLogging
 5 | import org.apache.commons.configuration2.Configuration
 6 | import java.time.Instant
 7 | 
 8 | class ConfigurationTokenContainer(
 9 |         private val config: Configuration
10 | ) : HelixTokenContainer {
11 | 
12 |     private companion object {
13 |         val logger = KotlinLogging.logger {}
14 |     }
15 | 
16 |     override fun getToken(): HelixApiToken? {
17 |         val expiresAt = Instant.ofEpochMilli(config.getLong(ConfigKeys.Twitch.expiresAt, 0))
18 |         val now = Instant.now()
19 |         if (now > expiresAt) {
20 |             return null
21 |         }
22 | 
23 |         return HelixApiToken(config.getString("twitch.bearer-token"), expiresAt)
24 |     }
25 | 
26 |     override fun setToken(token: HelixApiToken) {
27 |         config.setProperty(ConfigKeys.Twitch.token, token.value)
28 |         config.setProperty(ConfigKeys.Twitch.expiresAt, token.expiresAt.toEpochMilli())
29 |         logger.info("Helix token was saved to configuration")
30 |     }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/EmotesResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | data class EmotesResponse(
 4 |         val data: List
 5 | ) {
 6 | 
 7 |     data class Data(
 8 |             val id: String,
 9 |             val name: String
10 |     )
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzApiClient.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import com.fasterxml.jackson.databind.ObjectMapper
 4 | import failchat.util.await
 5 | import failchat.util.getBodyIfStatusIs
 6 | import failchat.util.nonNullBody
 7 | import failchat.util.withSuffix
 8 | import okhttp3.OkHttpClient
 9 | import okhttp3.Request
10 | 
11 | class FfzApiClient(
12 |         private val httpClient: OkHttpClient,
13 |         private val objectMapper: ObjectMapper,
14 |         apiUrl: String
15 | ) {
16 | 
17 |     private val apiUrl = apiUrl.withSuffix("/")
18 | 
19 |     /** @throws [FfzChannelNotFoundException]. */
20 |     suspend fun requestEmoticons(roomName: String): List {
21 |         val request = Request.Builder()
22 |                 .url(apiUrl + "room/" + roomName)
23 |                 .get()
24 |                 .build()
25 | 
26 |         val parsedBody = httpClient.newCall(request).await().use {
27 |             if (it.code == 404) throw FfzChannelNotFoundException(roomName)
28 |             val bodyText = it.getBodyIfStatusIs(200).nonNullBody.string()
29 |             objectMapper.readTree(bodyText)
30 |         }
31 | 
32 |         val emoticonSet = parsedBody.get("room").get("set").longValue()
33 | 
34 |         return parsedBody
35 |                 .get("sets")
36 |                 .get(emoticonSet.toString())
37 |                 .get("emoticons")
38 |                 .map { emoticonNode ->
39 |                     FfzEmoticon(
40 |                         emoticonNode.get("name").textValue(),
41 |                         emoticonNode.get("urls").get("1").textValue()
42 |                     )
43 |                 }
44 |     }
45 | 
46 | }
47 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | class FfzChannelNotFoundException(val channel: String) : Exception()
4 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.ImageFormat
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class FfzEmoticon(
 8 |         code: String,
 9 |         override val url: String
10 | ) : Emoticon(Origin.FRANKERFASEZ, code, ImageFormat.RASTER)
11 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzEmoticonHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.MessageHandler
 5 | import failchat.emoticon.EmoticonFinder
 6 | import failchat.emoticon.ReplaceDecision
 7 | import failchat.emoticon.WordReplacer
 8 | 
 9 | class FfzEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 | 
11 |     override fun handleMessage(message: TwitchMessage) {
12 |         message.text = WordReplacer.replace(message.text) { code ->
13 |             val emoticon = emoticonFinder.findByCode(Origin.FRANKERFASEZ, code)
14 |                     ?: return@replace ReplaceDecision.Skip
15 |             val label = message.addElement(emoticon)
16 |             return@replace ReplaceDecision.Replace(label)
17 |         }
18 |     }
19 | 
20 | }
21 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/HelixApiToken.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | import java.time.Instant
4 | 
5 | data class HelixApiToken(
6 |         val value: String,
7 |         val expiresAt: Instant
8 | )
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/HelixTokenContainer.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | interface HelixTokenContainer {
4 |     fun getToken(): HelixApiToken?
5 |     fun setToken(token: HelixApiToken)
6 | }
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/InvalidTokenException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | class InvalidTokenException : RuntimeException("Invalid twitch token")
4 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/RangedEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | class RangedEmoticon(val emoticon: TwitchEmoticon, val position: IntRange)
4 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | class SevenTvChannelNotFoundException(val channelId: Long) : Exception()
4 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvChannelResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 | 
5 | data class SevenTvChannelResponse(
6 |         @JsonProperty("emote_set")
7 |         val emoteSet: SevenTvEmoteSetResponse
8 | )
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoteSetResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | data class SevenTvEmoteSetResponse(
 4 |         val emotes: List = listOf()
 5 | ) {
 6 | 
 7 |     data class Emote(
 8 |             val id: String,
 9 |             val name: String,
10 |             val data: Data
11 |     )
12 | 
13 |     data class Data(
14 |             val host: Host
15 |     )
16 | 
17 |     data class Host(
18 |             val url: String,
19 |             val files: List
20 |     )
21 | 
22 |     data class File(
23 |             val name: String,
24 |             val format: String,
25 |             val width: Int
26 |     )
27 | }
28 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.ImageFormat
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class SevenTvEmoticon(
 8 |         origin: Origin,
 9 |         code: String,
10 |         val id: String,
11 |         override val url: String
12 | ) : Emoticon(origin, code, ImageFormat.RASTER)
13 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonIdExtractor
 5 | 
 6 | object SevenTvEmoticonIdExtractor : EmoticonIdExtractor {
 7 | 
 8 |     override val origin = Origin.SEVEN_TV_GLOBAL
 9 | 
10 |     override fun extractId(emoticon: SevenTvEmoticon): String {
11 |         return emoticon.id
12 |     }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvGlobalEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoadConfiguration
 5 | 
 6 | class SevenTvGlobalEmoticonLoadConfiguration(
 7 |         override val loader: SevenTvGlobalEmoticonLoader
 8 | ) : EmoticonLoadConfiguration {
 9 |     override val origin = Origin.SEVEN_TV_GLOBAL
10 |     override val idExtractor = SevenTvEmoticonIdExtractor
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoader
 5 | import kotlinx.coroutines.CoroutineScope
 6 | import kotlinx.coroutines.Dispatchers
 7 | import kotlinx.coroutines.future.future
 8 | import java.util.concurrent.CompletableFuture
 9 | 
10 | class SevenTvGlobalEmoticonLoader(private val sevenTvApiClient: SevenTvApiClient) : EmoticonLoader {
11 | 
12 |     override val origin = Origin.SEVEN_TV_GLOBAL
13 | 
14 |     override fun loadEmoticons(): CompletableFuture> {
15 |         return CoroutineScope(Dispatchers.Default).future {
16 |             sevenTvApiClient.loadGlobalEmoticons()
17 |         }
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/StreamsResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import com.fasterxml.jackson.annotation.JsonProperty
 4 | 
 5 | data class StreamsResponse(
 6 |         val data: List
 7 | ) {
 8 |     data class Data(
 9 |             @JsonProperty("viewer_count")
10 |             val viewerCount: Int,
11 |             @JsonProperty("user_login")
12 |             val userLogin: String
13 |     )
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TokenAwareTwitchApiClient.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.badge.ImageBadge
 4 | import kotlinx.coroutines.sync.Mutex
 5 | import kotlinx.coroutines.sync.withLock
 6 | 
 7 | /**
 8 |  * The [TwitchApiClient] wrapper that:
 9 |  * - reuses existing token.
10 |  * - retries the request if the token is expired.
11 |  * */
12 | class TokenAwareTwitchApiClient(
13 |         private val twitchApiClient: TwitchApiClient,
14 |         private val clientSecret: String,
15 |         private val tokenContainer: HelixTokenContainer
16 | ) {
17 | 
18 |     private val mutex = Mutex() // forbid parallel execution to prevent multiple tokens generation in the same time
19 | 
20 |     suspend fun getUserId(userName: String): Long {
21 |         return mutex.withLock {
22 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
23 |                 twitchApiClient.getUserId(userName, it)
24 |             }
25 |         }
26 |     }
27 | 
28 |     suspend fun getViewersCount(userName: String): Int {
29 |         return mutex.withLock {
30 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
31 |                 twitchApiClient.getViewersCount(userName, it)
32 |             }
33 |         }
34 |     }
35 | 
36 |     suspend fun getGlobalEmoticons(): List {
37 |         return mutex.withLock {
38 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
39 |                 twitchApiClient.getGlobalEmoticons(it)
40 |             }
41 |         }
42 |     }
43 | 
44 |     suspend fun getFirstLiveChannelName(): String {
45 |         return mutex.withLock {
46 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
47 |                 twitchApiClient.getFirstLiveChannelName(it)
48 |             }
49 |         }
50 |     }
51 | 
52 |     suspend fun getGlobalBadges(): Map {
53 |         return mutex.withLock {
54 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
55 |                 twitchApiClient.getGlobalBadges(it)
56 |             }
57 |         }
58 |     }
59 | 
60 |     suspend fun getChannelBadges(channelId: Long): Map {
61 |         return mutex.withLock {
62 |             doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
63 |                 twitchApiClient.getChannelBadges(channelId, it)
64 |             }
65 |         }
66 |     }
67 | }
68 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchAuthorColorHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | import failchat.util.notEmptyOrNull
 5 | import javafx.scene.paint.Color
 6 | 
 7 | class TwitchAuthorColorHandler : MessageHandler {
 8 | 
 9 |     override fun handleMessage(message: TwitchMessage) {
10 |         val colorString = message.tags.get(TwitchIrcTags.color).notEmptyOrNull() ?: return
11 |         message.author.color = Color.web(colorString)
12 |     }
13 | 
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchBadgeHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | import failchat.chat.badge.Badge
 5 | import failchat.chat.badge.BadgeFinder
 6 | import failchat.chat.badge.BadgeOrigin.TWITCH_CHANNEL
 7 | import failchat.chat.badge.BadgeOrigin.TWITCH_GLOBAL
 8 | import failchat.util.notEmptyOrNull
 9 | import mu.KotlinLogging
10 | 
11 | class TwitchBadgeHandler(
12 |         private val badgeFinder: BadgeFinder
13 | ) : MessageHandler {
14 | 
15 |     private companion object {
16 |         val logger = KotlinLogging.logger {}
17 |     }
18 | 
19 |     override fun handleMessage(message: TwitchMessage) {
20 |         val badgesTag = message.tags.get(TwitchIrcTags.badges).notEmptyOrNull() ?: return
21 | 
22 |         val messageBadgeIds = parseBadgesTag(badgesTag)
23 | 
24 |         messageBadgeIds.forEach { messageBadgeId ->
25 |             val badge: Badge? = badgeFinder.findBadge(TWITCH_CHANNEL, messageBadgeId)
26 |                     ?: badgeFinder.findBadge(TWITCH_GLOBAL, messageBadgeId)
27 | 
28 |             if (badge == null) {
29 |                 logger.debug("Badge not found. Origin: {}, {}; badge id: {}", TWITCH_CHANNEL, TWITCH_GLOBAL, messageBadgeId)
30 |                 return@forEach
31 |             }
32 | 
33 |             message.addBadge(badge)
34 |         }
35 |     }
36 | 
37 |     private fun parseBadgesTag(badgesTag: String): List {
38 |         return badgesTag
39 |                 .split(',')
40 |                 .asSequence()
41 |                 .map { it.split('/', limit = 2) }
42 |                 .map {
43 |                     val version = if (it.size < 2) "" else it[1]
44 |                     TwitchBadgeId(it[0], version)
45 |                 }
46 |                 .toList()
47 |     }
48 | 
49 | }
50 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchBadgeId.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | data class TwitchBadgeId(
4 |         val setId: String,
5 |         val version: String
6 | )


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmotesTagParser.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | class TwitchEmotesTagParser {
 4 | 
 5 |     fun parse(emotesTag: String, messageText: String): List {
 6 |         /*
 7 |         * Message example:
 8 |         * emotes tag = "25:0-4,10-14/1902:16-20"
 9 |         * text = "Kappa 123 Kappa Keepo"
10 |         *
11 |         * If there is no emotes, emotesTag == null
12 |         * */
13 |         return emotesTag
14 |                 .split("/")
15 |                 .flatMap { emoteWithPositions ->
16 |                     val (emoticonId, positionsString) = emoteWithPositions.split(":", limit = 2)
17 | 
18 |                     val positions = positionsString
19 |                             .split(",")
20 |                             .map {
21 |                                 val (start, end) = it.split("-", limit = 2)
22 |                                 IntRange(start.toInt(), end.toInt())
23 |                             }
24 | 
25 |                     positions.map {
26 |                         val emoticon = TwitchEmoticon(
27 |                                 twitchId = emoticonId,
28 |                                 code = messageText.substring(it)
29 |                         )
30 | 
31 |                         RangedEmoticon(emoticon, it)
32 |                     }
33 | 
34 |                 }
35 |                 .sortedBy { it.position.start }
36 |     }
37 | }
38 | 
39 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.ImageFormat.RASTER
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class TwitchEmoticon(
 8 |         val twitchId: String,
 9 |         code: String
10 | ) : Emoticon(Origin.TWITCH, code, RASTER) {
11 | 
12 |     override val url: String
13 |         get() = TwitchEmoticonUrlFactory.create(twitchId)
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | import failchat.util.notEmptyOrNull
 5 | import mu.KotlinLogging
 6 | 
 7 | class TwitchEmoticonHandler(
 8 |         private val twitchEmotesTagParser: TwitchEmotesTagParser
 9 | ) : MessageHandler {
10 | 
11 |     private companion object {
12 |         val logger = KotlinLogging.logger {}
13 |     }
14 | 
15 |     override fun handleMessage(message: TwitchMessage) {
16 |         val emotesTag = message.tags.get(TwitchIrcTags.emotes).notEmptyOrNull() ?: return
17 | 
18 |         try {
19 |             replaceEmoteCodes(emotesTag, message)
20 |         } catch (e: Exception) {
21 |             logger.warn(e) { "Failed to replace emoticon codes with element labels. emotes tag: '$emotesTag', " +
22 |                     "message text: '${message.text}'" }
23 |         }
24 |     }
25 | 
26 |     private fun replaceEmoteCodes(emotesTag: String, message: TwitchMessage) {
27 |         val rangedEmoticons = twitchEmotesTagParser.parse(emotesTag, message.text)
28 | 
29 |         var offset = 0
30 |         val sb = StringBuilder(message.text)
31 |         rangedEmoticons.forEach { item ->
32 |             val elementLabel = message.addElement(item.emoticon)
33 |             val labelLength = elementLabel.length
34 |             val position = item.position
35 | 
36 |             sb.replace(position.start + offset, position.endInclusive + 1 + offset, elementLabel)
37 |             // positive - to the right, negative - to the left
38 |             offset += labelLength - (position.endInclusive - position.start + 1)
39 |         }
40 | 
41 |         message.text = sb.toString()
42 | 
43 |     }
44 | 
45 | }
46 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonIdExtractor
 5 | 
 6 | object TwitchEmoticonIdExtractor: EmoticonIdExtractor {
 7 |     override val origin = Origin.TWITCH
 8 | 
 9 |     override fun extractId(emoticon: TwitchEmoticon): String {
10 |         return emoticon.twitchId.toString()
11 |     }
12 | }
13 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoadConfiguration
 5 | 
 6 | class TwitchEmoticonLoadConfiguration(
 7 |         override val loader: TwitchGlobalEmoticonLoader
 8 | ) : EmoticonLoadConfiguration {
 9 |     override val origin = Origin.TWITCH
10 |     override val idExtractor = TwitchEmoticonIdExtractor
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonUrlFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 | 
3 | object TwitchEmoticonUrlFactory {
4 |     fun create(id: String): String {
5 |         return "https://static-cdn.jtvnw.net/emoticons/v2/$id/default/light/1.0"
6 |     }
7 | }
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.emoticon.EmoticonLoader
 5 | import kotlinx.coroutines.CoroutineScope
 6 | import kotlinx.coroutines.Dispatchers
 7 | import kotlinx.coroutines.future.future
 8 | import java.util.concurrent.CompletableFuture
 9 | 
10 | /** Uses official twitch API. */
11 | class TwitchGlobalEmoticonLoader(
12 |         private val twitchClient: TokenAwareTwitchApiClient
13 | ) : EmoticonLoader {
14 | 
15 |     override val origin = Origin.TWITCH
16 | 
17 |     override fun loadEmoticons(): CompletableFuture> {
18 |         return CoroutineScope(Dispatchers.Default).future { twitchClient.getGlobalEmoticons() }
19 |     }
20 | }
21 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchHighlightByPointsHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | 
 5 | class TwitchHighlightByPointsHandler : MessageHandler {
 6 | 
 7 |     override fun handleMessage(message: TwitchMessage) {
 8 |         if (message.tags.get(TwitchIrcTags.msgId) == "highlighted-message") {
 9 |             message.highlightedBackground = true
10 |         }
11 |     }
12 | 
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchHighlightHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | 
 5 | class TwitchHighlightHandler(channel: String) : MessageHandler {
 6 | 
 7 |     private val appeal = "@" + channel
 8 | 
 9 |     override fun handleMessage(message: TwitchMessage) {
10 |         if (message.text.contains(appeal, ignoreCase = true)) {
11 |             message.highlighted = true
12 |         }
13 |     }
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchIrcTags.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | object TwitchIrcTags {
 4 |     const val emotes = "emotes"
 5 |     const val badges = "badges"
 6 |     const val customRewardId = "custom-reward-id"
 7 |     const val msgId = "msg-id"
 8 |     const val color = "color"
 9 |     const val displayName = "display-name"
10 | }
11 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchMessage.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.Author
 5 | import failchat.chat.ChatMessage
 6 | 
 7 | class TwitchMessage(
 8 |         id: Long,
 9 |         author: String,
10 |         text: String,
11 |         val tags: Map
12 | ) : ChatMessage(id, Origin.TWITCH, Author(author, Origin.TWITCH), text)
13 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchRetries.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import mu.KotlinLogging
 4 | 
 5 | val logger = KotlinLogging.logger {}
 6 | 
 7 | suspend fun  doWithRetryOnAuthError(
 8 |         twitchApiClient: TwitchApiClient,
 9 |         clientSecret: String,
10 |         tokenContainer: HelixTokenContainer,
11 |         operation: suspend (String) -> T
12 | ): T {
13 |     val existingToken = tokenContainer.getToken()
14 | 
15 |     if (existingToken != null) {
16 |         try {
17 |             return operation(existingToken.value)
18 |         } catch (e: InvalidTokenException) {
19 |             logger.info("Invalid twitch token")
20 |         }
21 |     }
22 | 
23 |     val newToken = twitchApiClient.generateToken(clientSecret)
24 |     tokenContainer.setToken(newToken)
25 |     return operation(newToken.value)
26 | }
27 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchRewardHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | 
 5 | class TwitchRewardHandler : MessageHandler {
 6 | 
 7 |     override fun handleMessage(message: TwitchMessage) {
 8 |         if (message.tags.get(TwitchIrcTags.customRewardId) != null) {
 9 |             message.highlightedBackground = true
10 |         }
11 |     }
12 | 
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchViewersCountLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.Origin
 4 | import failchat.viewers.ViewersCountLoader
 5 | import kotlinx.coroutines.CoroutineScope
 6 | import kotlinx.coroutines.Dispatchers
 7 | import kotlinx.coroutines.future.future
 8 | import java.util.concurrent.CompletableFuture
 9 | 
10 | class TwitchViewersCountLoader(
11 |         private val userName: String,
12 |         private val twitchClient: TokenAwareTwitchApiClient
13 | ) : ViewersCountLoader {
14 | 
15 |     override val origin = Origin.TWITCH
16 | 
17 |     override fun loadViewersCount(): CompletableFuture {
18 |         return CoroutineScope(Dispatchers.Default).future { twitchClient.getViewersCount(userName) }
19 |     }
20 | }
21 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/UsersResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | data class UsersResponse(
 4 |         val data: List
 5 | ) {
 6 |     data class Data(
 7 |             val id: Long
 8 |     )
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Collections.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 | 
3 | import java.util.EnumMap
4 | import java.util.EnumSet
5 | 
6 | inline fun , V> enumMap(): EnumMap = java.util.EnumMap(K::class.java)
7 | inline fun > enumSet(): EnumSet = java.util.EnumSet.noneOf(T::class.java)
8 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/CompletableFutures.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import mu.KotlinLogging
 4 | import java.time.Duration
 5 | import java.util.concurrent.CompletableFuture
 6 | import java.util.concurrent.CompletionException
 7 | import java.util.concurrent.CompletionStage
 8 | import java.util.concurrent.ExecutionException
 9 | import java.util.concurrent.TimeUnit
10 | 
11 | private val logger = KotlinLogging.logger {}
12 | 
13 | fun Collection>.compose(): CompletableFuture {
14 |     return CompletableFuture.allOf(*this.toTypedArray()) // excessive array copying here because of spread operator
15 | }
16 | 
17 | fun  CompletableFuture.get(timeout: Duration): T = this.get(timeout.toMillis(), TimeUnit.MILLISECONDS)
18 | 
19 | fun  completedFuture(value: T): CompletableFuture = CompletableFuture.completedFuture(value)
20 | fun completedFuture(): CompletableFuture = CompletableFuture.completedFuture(Unit)
21 | 
22 | fun  exceptionalFuture(exception: Throwable): CompletableFuture {
23 |     return CompletableFuture().apply { completeExceptionally(exception) }
24 | }
25 | 
26 | /**
27 |  * Unwrap [CompletionException] and return it's cause.
28 |  * @throws NullCompletionCauseException if cause of [CompletionException] is null.
29 |  * */
30 | fun Throwable.completionCause(): Throwable {
31 |     return if (this is CompletionException) {
32 |         this.cause ?: throw NullCompletionCauseException(this)
33 |     } else {
34 |         this
35 |     }
36 | }
37 | 
38 | private class NullCompletionCauseException(e: CompletionException) : Exception(e)
39 | 
40 | fun  CompletionStage.logException() {
41 |     whenComplete { _, t ->
42 |         if (t !== null) logger.error("Unhandled exception from CompletionStage", t)
43 |     }
44 | }
45 | 
46 | /**
47 |  * Execute operation and close the [T].
48 |  * */
49 | inline fun  CompletableFuture.thenUse(crossinline operation: (T) -> R): CompletableFuture {
50 |     return this.thenApply { response ->
51 |         response.use(operation)
52 |     }
53 | }
54 | 
55 | /**
56 |  * Perform [action], if it throws [ExecutionException] cause will be thrown instead.
57 |  * */
58 | inline fun  doUnwrappingExecutionException(action: () -> T): T {
59 |     try {
60 |         return action.invoke()
61 |     } catch (e: ExecutionException) {
62 |         throw e.cause ?: e
63 |     }
64 | }
65 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Concurrent.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import mu.KotlinLogging
 4 | import java.time.Duration
 5 | import java.util.concurrent.ExecutorService
 6 | import java.util.concurrent.ScheduledExecutorService
 7 | import java.util.concurrent.ScheduledFuture
 8 | import java.util.concurrent.TimeUnit
 9 | import java.util.concurrent.atomic.AtomicBoolean
10 | import java.util.concurrent.atomic.AtomicReference
11 | import java.util.concurrent.locks.Condition
12 | 
13 | private val logger = KotlinLogging.logger {}
14 | 
15 | fun sleep(duration: Duration) = Thread.sleep(duration.toMillis())
16 | 
17 | fun Condition.await(duration: Duration) = this.await(duration.toMillis(), TimeUnit.MILLISECONDS)
18 | 
19 | fun ScheduledExecutorService.schedule(delay: Duration, command: () -> Unit): ScheduledFuture<*> {
20 |     return this.schedule(command, delay.toMillis(), TimeUnit.MILLISECONDS)
21 | }
22 | 
23 | fun ScheduledExecutorService.scheduleWithCatch(delay: Duration, command: () -> Unit): ScheduledFuture<*> {
24 |     return schedule(delay) {
25 |         try {
26 |             command.invoke()
27 |         } catch (t: Throwable) {
28 |             logger.error("Uncaught exception during executing scheduled task $command", t)
29 |         }
30 |     }
31 | }
32 | 
33 | inline var  AtomicReference.value
34 |     get(): T = this.get()
35 |     set(value: T) = this.set(value)
36 | 
37 | fun ExecutorService.executeWithCatch(task: () -> Unit) {
38 |     return execute {
39 |         try {
40 |             task.invoke()
41 |         } catch (t: Throwable) {
42 |             logger.error("Uncaught exception", t)
43 |         }
44 |     }
45 | }
46 | 
47 | inline var AtomicBoolean.value
48 |     get(): Boolean = this.get()
49 |     set(value: Boolean) = this.set(value)
50 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Config.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import org.apache.commons.configuration2.Configuration
 4 | 
 5 | /**
 6 |  * Invert boolean value by specified [key] and return the new value.
 7 |  * */
 8 | fun Configuration.invertBoolean(key: String): Boolean {
 9 |     val newValue = !getBoolean(key)
10 |     setProperty(key, newValue)
11 |     return newValue
12 | }
13 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Coroutines.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import kotlinx.coroutines.CoroutineExceptionHandler
 4 | import kotlinx.coroutines.CoroutineName
 5 | import kotlinx.coroutines.channels.SendChannel
 6 | import mu.KotlinLogging
 7 | import kotlin.coroutines.CoroutineContext
 8 | 
 9 | object CoroutineExceptionLogger : CoroutineExceptionHandler {
10 | 
11 |     private val logger = KotlinLogging.logger {}
12 | 
13 |     override val key = CoroutineExceptionHandler.Key
14 | 
15 |     override fun handleException(context: CoroutineContext, exception: Throwable) {
16 |         logger.error(exception) { "Uncaught exception in coroutine '${context[CoroutineName.Key]?.name}'" }
17 |     }
18 | }
19 | 
20 | fun  SendChannel.offerOrThrow(element: T) {
21 |     val offered = offer(element)
22 |     if (!offered) throw RuntimeException("SendChannel.offer operation failed")
23 | }
24 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Eithers.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 | 
3 | import either.Either
4 | import either.fold
5 | 
6 | fun  Either.any() = fold({ it }, { it })
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Files.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 | 
3 | import ch.qos.logback.core.util.FileSize
4 | 
5 | fun Long.bytesToMegabytes() = this / FileSize.MB_COEFFICIENT
6 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Ints.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | fun Int.binary(): String {
 4 |     val binaryInt = Integer.toBinaryString(this)
 5 |     if (binaryInt.length >= 32) return binaryInt
 6 | 
 7 |     val sb = StringBuilder(32)
 8 |     repeat(32 - binaryInt.length) {
 9 |         sb.append('0')
10 |     }
11 |     sb.append(binaryInt)
12 | 
13 |     return sb.toString()
14 | }
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Jackson.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 | 
3 | import com.fasterxml.jackson.databind.JsonNode
4 | 
5 | fun JsonNode.isEmpty(): Boolean = this.size() == 0
6 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/JavaFx.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import javafx.scene.paint.Color
 4 | 
 5 | fun Color.toHexFormat(): String {
 6 |     val r = Math.round(red * 255.0).toInt()
 7 |     val g = Math.round(green * 255.0).toInt()
 8 |     val b = Math.round(blue * 255.0).toInt()
 9 |     val o = Math.round(opacity * 255.0).toInt()
10 |     return String.format("#%02x%02x%02x%02x", r, g, b, o)
11 | }
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Json.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import com.fasterxml.jackson.core.JsonParser
 4 | import com.fasterxml.jackson.core.JsonToken
 5 | import com.fasterxml.jackson.databind.DeserializationFeature
 6 | import com.fasterxml.jackson.databind.ObjectMapper
 7 | import com.fasterxml.jackson.module.kotlin.KotlinModule
 8 | 
 9 | fun objectMapper(): ObjectMapper = ObjectMapper()
10 |         .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
11 |         .registerModule(KotlinModule())
12 | 
13 | /**
14 |  * Read the next token and assert that it is not null.
15 |  * @throws [UnexpectedJsonFormatException] if next token is null.
16 |  * */
17 | fun JsonParser.nextNonNullToken(): JsonToken {
18 |     return nextToken() ?: throw UnexpectedJsonFormatException("Failed to get next token, end of data stream")
19 | }
20 | 
21 | fun JsonToken.validate(expected: JsonToken): JsonToken {
22 |     if (this != expected) {
23 |         throw UnexpectedJsonFormatException("Expected '$expected' json token, got '$this'")
24 |     }
25 |     return this
26 | }
27 | 
28 | /**
29 |  * Read next non-null token and assert that it's value is equal to [expected] token. Blocking operation.
30 |  * @throws [UnexpectedJsonFormatException] if next token is null or doesn't equal to [expected] token.
31 |  * */
32 | fun JsonParser.expect(expected: JsonToken): JsonToken {
33 |     return nextNonNullToken().validate(expected)
34 | }
35 | 
36 | class UnexpectedJsonFormatException : Exception {
37 |     constructor() : super()
38 |     constructor(message: String?) : super(message)
39 |     constructor(message: String?, cause: Throwable?) : super(message, cause)
40 |     constructor(cause: Throwable?) : super(cause)
41 | }
42 | 
43 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Lang.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | inline fun  whileNotNull(supplier: () -> T, operation: (T) -> Unit) {
 4 |     var value = supplier.invoke()
 5 |     while (value != null) {
 6 |         operation.invoke(value)
 7 |         value = supplier.invoke()
 8 |     }
 9 | }
10 | 
11 | fun Collection<*>?.isNullOrEmpty(): Boolean {
12 |     if (this == null) return true
13 |     if (this.isEmpty()) return true
14 |     return false
15 | }
16 | 
17 | /** Useful for `when` statement with sealed classes. */
18 | object Do {
19 |     inline infix fun  exhaustive(any: T?) = any
20 | }
21 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/LateinitVal.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | /** Thread safe lateinit value. */
 4 | class LateinitVal {
 5 | 
 6 |     private var v: T? = null
 7 | 
 8 |     /**
 9 |      * Get the value.
10 |      * @return null if value is is not initialized yet.
11 |      * */
12 |     fun get(): T? {
13 |         if (v != null) return v
14 | 
15 |         // values is not initialized or not published
16 |         synchronized(this) {
17 |             return v
18 |         }
19 |     }
20 | 
21 |     /**
22 |      * Set the value.
23 |      * @throws [IllegalStateException] if value already initialized.
24 |      * */
25 |     fun set(value: T) {
26 |         synchronized(this) {
27 |             if (v != null) throw IllegalStateException("Value already initialized")
28 |             v = value
29 |         }
30 |     }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/OkHttp.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import failchat.exception.UnexpectedResponseCodeException
 4 | import failchat.exception.UnexpectedResponseException
 5 | import okhttp3.Call
 6 | import okhttp3.Callback
 7 | import okhttp3.MediaType
 8 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
 9 | import okhttp3.RequestBody
10 | import okhttp3.Response
11 | import okhttp3.ResponseBody
12 | import java.io.IOException
13 | import java.util.concurrent.CompletableFuture
14 | import kotlin.coroutines.resume
15 | import kotlin.coroutines.resumeWithException
16 | import kotlin.coroutines.suspendCoroutine
17 | 
18 | val jsonMediaType: MediaType = "application/json".toMediaTypeOrNull()!!
19 | val textMediaType: MediaType = "text/plain".toMediaTypeOrNull()!!
20 | val emptyBody: RequestBody = RequestBody.create(textMediaType, "")
21 | 
22 | fun Call.toFuture(): CompletableFuture {
23 |     val future = CompletableFuture()
24 |     this.enqueue(object : Callback {
25 |         override fun onFailure(call: Call, e: IOException) {
26 |             future.completeExceptionally(e)
27 |         }
28 | 
29 |         override fun onResponse(call: Call, response: Response) {
30 |             future.complete(response)
31 |         }
32 |     })
33 |     return future
34 | }
35 | 
36 | suspend fun Call.await(): Response {
37 |     return suspendCoroutine { continuation ->
38 |         this.enqueue(object : Callback {
39 |             override fun onResponse(call: Call, response: Response) {
40 |                 continuation.resume(response)
41 |             }
42 | 
43 |             override fun onFailure(call: Call, e: IOException) {
44 |                 continuation.resumeWithException(e)
45 |             }
46 |         })
47 |     }
48 | }
49 | 
50 | fun Response.getBodyIfStatusIs(expectedStatus: Int): Response {
51 |     if (this.code != expectedStatus) {
52 |         throw UnexpectedResponseCodeException(this.code, request.url.toString())
53 |     }
54 |     return this
55 | }
56 | 
57 | val Response.nonNullBody: ResponseBody
58 |     get() = this.body ?: throw UnexpectedResponseException("null body")
59 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/OkHttpLogger.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import mu.KotlinLogging
 4 | import okhttp3.logging.HttpLoggingInterceptor
 5 | 
 6 | object OkHttpLogger : HttpLoggingInterceptor.Logger {
 7 | 
 8 |     private val logger = KotlinLogging.logger { }
 9 | 
10 |     override fun log(message: String) {
11 |         logger.debug(message)
12 |     }
13 | }
14 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Streams.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 | 
3 | import java.util.stream.Stream
4 | 
5 | fun  Stream.filterNotNull(): Stream {
6 |     @Suppress("UNCHECKED_CAST")
7 |     return filter { it != null } as Stream
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Strings.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | fun String.withPrefix(prefix: String): String {
 4 |     if (this.startsWith(prefix)) return this
 5 |     return prefix + this
 6 | }
 7 | 
 8 | fun String.withSuffix(suffix: String): String {
 9 |     if (this.endsWith(suffix)) return this
10 |     return this + suffix
11 | }
12 | 
13 | fun String?.notEmptyOrNull(): String? {
14 |     if (this.isNullOrEmpty()) return null
15 |     return this
16 | }
17 | 
18 | fun String.endsWithAny(suffixes: Iterable): Boolean {
19 |     return suffixes.any { suffix ->
20 |         this.endsWith(suffix)
21 |     }
22 | }
23 | 
24 | fun Int.toHexString(): String = java.lang.Integer.toHexString(this)
25 | 
26 | fun toCodePoint(high: Char, low: Char): Int = java.lang.Character.toCodePoint(high, low)
27 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Systems.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | /**
 4 |  * Line separator shortcut.
 5 |  * */
 6 | inline val ls: String get() = System.lineSeparator()
 7 | 
 8 | /**
 9 |  * Shortcut for [System.getProperty].
10 |  * */
11 | fun sp(key: String): String? = System.getProperty(key)
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Threads.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | val hotspotThreads: Set = setOf("Attach Listener", "Disposer", "Finalizer", "Prism Font Disposer",
 4 |         "Reference Handler", "Signal Dispatcher","DestroyJavaVM")
 5 | 
 6 | fun formatStackTraces(stackTraces: Map>): String {
 7 |     return stackTraces
 8 |             .map { (thread, stackTraceElements) ->
 9 |                 with(thread) {
10 |                     "Thread[name=$name; id=$id; state=$state; isDaemon=$isDaemon; isInterrupted=$isInterrupted; " +
11 |                             "priority=$priority; threadGroup=${threadGroup.name}]"
12 |                 } +
13 |                         if (stackTraceElements.isEmpty()) ""
14 |                         else stackTraceElements.joinToString(prefix = ls + "\t", separator = ls + "\t")
15 |             }
16 |             .joinToString(separator = ls)
17 | }
18 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Urls.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import java.util.regex.Pattern
 4 | 
 5 | /**
 6 |  * URL pattern.
 7 |  *
 8 |  * Capture groups:
 9 |  * 1. protocol (http, https, ftp, ftps)
10 |  * 2. "www."
11 |  * 3. short url
12 |  * 4. domain
13 |  */
14 | val urlPattern: Pattern = Pattern.compile("""\b(https?|ftps?)://(w{3}\.)?(([-\w\d+&@#%?=~_|!:,.;]+)[/\S]*)""")
15 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/CountableOrigins.kt:
--------------------------------------------------------------------------------
 1 | package failchat.viewers
 2 | 
 3 | import failchat.Origin
 4 | import failchat.Origin.GOODGAME
 5 | import failchat.Origin.TWITCH
 6 | import failchat.Origin.YOUTUBE
 7 | import java.util.EnumSet
 8 | 
 9 | val COUNTABLE_ORIGINS: Set = EnumSet.of(GOODGAME, TWITCH, YOUTUBE)
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/ViewersCountLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.viewers
 2 | 
 3 | import failchat.Origin
 4 | import java.util.concurrent.CompletableFuture
 5 | 
 6 | interface ViewersCountLoader {
 7 |     val origin: Origin
 8 |     fun loadViewersCount(): CompletableFuture
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/ViewersCountWsHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.viewers
 2 | 
 3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
 4 | import failchat.ws.server.InboundWsMessage
 5 | import failchat.ws.server.WsMessageHandler
 6 | import io.ktor.http.cio.websocket.Frame
 7 | import org.apache.commons.configuration2.Configuration
 8 | 
 9 | 
10 | class ViewersCountWsHandler(
11 |         private val config: Configuration
12 | ) : WsMessageHandler {
13 | 
14 |     override val expectedType = InboundWsMessage.Type.VIEWERS_COUNT
15 | 
16 |     @Volatile
17 |     var viewersCounter: ViewersCounter? = null
18 | 
19 |     private val nodeFactory: JsonNodeFactory = JsonNodeFactory.instance
20 | 
21 |     
22 |     override fun handle(message: InboundWsMessage) {
23 |         viewersCounter?.let {
24 |             it.sendViewersCountWsMessage()
25 |             return
26 |         }
27 | 
28 |         // viewersCounter is not set yet
29 |         // send message with null values for enabled origins
30 |         val enabledOrigins = COUNTABLE_ORIGINS.filter {
31 |             config.getBoolean("${it.commonName}.enabled")
32 |         }
33 | 
34 |         val messageNode = nodeFactory.objectNode().apply {
35 |             put("type", "viewers-count")
36 |             putObject("content").apply {
37 |                 enabledOrigins.forEach { putNull(it.commonName) }
38 |             }
39 |         }
40 | 
41 |         message.session.outgoing.offer(Frame.Text(messageNode.toString()))
42 |     }
43 | 
44 | }
45 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/ClientConfigurationWsHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.ws.server
 2 | 
 3 | import failchat.chat.ChatMessageSender
 4 | 
 5 | class ClientConfigurationWsHandler(
 6 |         private val messageSender: ChatMessageSender
 7 | ) : WsMessageHandler {
 8 | 
 9 |     override val expectedType = InboundWsMessage.Type.CLIENT_CONFIGURATION
10 | 
11 |     override fun handle(message: InboundWsMessage) {
12 |         messageSender.sendClientConfiguration()
13 |     }
14 | 
15 | }
16 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/DeleteWsMessageHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.ws.server
 2 | 
 3 | import failchat.chat.ChatMessageRemover
 4 | 
 5 | class DeleteWsMessageHandler(
 6 |         private val chatMessageRemover: ChatMessageRemover
 7 | ) : WsMessageHandler {
 8 | 
 9 |     override val expectedType = InboundWsMessage.Type.DELETE_MESSAGE
10 | 
11 |     override fun handle(message: InboundWsMessage) {
12 |         chatMessageRemover.remove(message.content.get("messageId").asLong())
13 |     }
14 | 
15 | }
16 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/IgnoreWsMessageHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.ws.server
 2 | 
 3 | import failchat.ConfigKeys
 4 | import failchat.chat.handlers.IgnoreFilter
 5 | import org.apache.commons.configuration2.Configuration
 6 | 
 7 | class IgnoreWsMessageHandler(
 8 |         private val ignoreFilter: IgnoreFilter,
 9 |         private val config: Configuration
10 | ) : WsMessageHandler {
11 | 
12 |     override val expectedType = InboundWsMessage.Type.IGNORE_AUTHOR
13 | 
14 |     override fun handle(message: InboundWsMessage) {
15 |         val authorId = message.content.get("authorId").asText()
16 |         val updatedIgnoreSet = config.getStringArray(ConfigKeys.ignore)
17 |                 .toMutableSet()
18 |                 .apply { add(authorId) }
19 |         config.setProperty(ConfigKeys.ignore, updatedIgnoreSet)
20 | 
21 |         ignoreFilter.reloadConfig()
22 |     }
23 | 
24 | }
25 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/InboundWsMessage.kt:
--------------------------------------------------------------------------------
 1 | package failchat.ws.server
 2 | 
 3 | import com.fasterxml.jackson.databind.JsonNode
 4 | import io.ktor.websocket.DefaultWebSocketServerSession
 5 | 
 6 | class InboundWsMessage(
 7 |         val type: Type,
 8 |         val content: JsonNode,
 9 |         val session: DefaultWebSocketServerSession
10 | ) {
11 | 
12 |     enum class Type(val jsonRepresentation: String) {
13 |         CLIENT_CONFIGURATION("client-configuration"),
14 |         DELETE_MESSAGE("delete-message"),
15 |         IGNORE_AUTHOR("ignore-author"),
16 |         VIEWERS_COUNT("viewers-count"),
17 |         ORIGINS_STATUS("origins-status");
18 | 
19 |         companion object {
20 |             private val map = values()
21 |                     .map { it.jsonRepresentation to it }
22 |                     .toMap()
23 | 
24 |             fun from(string: String): Type? = map[string]
25 |         }
26 |     }
27 | }
28 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/WsMessageDispatcher.kt:
--------------------------------------------------------------------------------
 1 | package failchat.ws.server
 2 | 
 3 | import com.fasterxml.jackson.databind.ObjectMapper
 4 | import failchat.util.enumMap
 5 | import io.ktor.http.cio.websocket.Frame
 6 | import io.ktor.http.cio.websocket.readText
 7 | import io.ktor.websocket.DefaultWebSocketServerSession
 8 | import kotlinx.coroutines.channels.consumeEach
 9 | import mu.KotlinLogging
10 | 
11 | class WsMessageDispatcher(
12 |         private val objectMapper: ObjectMapper,
13 |         handlers: List
14 | ) {
15 | 
16 |     private companion object {
17 |         val logger = KotlinLogging.logger {}
18 |     }
19 | 
20 |     private val handlers: Map = handlers
21 |             .map { it.expectedType to it }
22 |             .toMap(enumMap())
23 | 
24 |     suspend fun handleWebSocket(session: DefaultWebSocketServerSession) {
25 |         session.incoming.consumeEach { frame ->
26 |             if (frame !is Frame.Text) {
27 |                 logger.warn("Non textual frame received: {}", frame)
28 |                 return@consumeEach
29 |             }
30 | 
31 |             val frameText = frame.readText()
32 |             logger.debug("Message received from a web socket client: {}", frameText)
33 | 
34 |             val parsedMessage = objectMapper.readTree(frameText)
35 |             val typeString: String = parsedMessage.get("type").asText()
36 |             val type = InboundWsMessage.Type.from(typeString)
37 |                     ?: run {
38 |                         logger.warn("Message received with unknown type '{}'", typeString)
39 |                         return@consumeEach
40 |                     }
41 | 
42 |             handlers[type]
43 |                     ?.handle(InboundWsMessage(type, parsedMessage.get("content"), session))
44 |                     ?: logger.warn("Message consumer not found for a message with a type '{}'. Message: {}", type, frameText)
45 |         }
46 |     }
47 | 
48 | }
49 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/WsMessageHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 | 
3 | interface WsMessageHandler {
4 |     val expectedType: InboundWsMessage.Type
5 |     fun handle(message: InboundWsMessage)
6 | }
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/LiveChatRequest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import com.fasterxml.jackson.databind.node.ArrayNode
 4 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
 5 | import com.fasterxml.jackson.databind.node.ObjectNode
 6 | 
 7 | data class LiveChatRequest(
 8 |         val context: Context = Context(),
 9 |         val continuation: String
10 | ) {
11 |     data class Context(
12 |             val client: Client = Client(),
13 |             val request: Request = Request(),
14 |             val user: ObjectNode = JsonNodeFactory.instance.objectNode(),
15 |             val clientScreenNonce: String = "MC4xNzQ1MzczNjgyNTc0MTI1"
16 |     )
17 | 
18 |     data class Client(
19 |             val hl: String = "en-GB",
20 |             val gl: String = "RU",
21 |             val visitorData: String = "CgtvaTIycV9CTXMwSSjUiOP5BQ%3D%3D",
22 |             val userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:79.0) Gecko/20100101 Firefox/79.0,gzip(gfe)",
23 |             val clientName: String = "WEB",
24 |             val clientVersion: String = "2.20200814.00.00",
25 |             val osName: String = "Windows",
26 |             val osVersion: String = "10.0",
27 |             val browserName: String = "Firefox",
28 |             val browserVersion: String = "79.0",
29 |             val screenWidthPoints: Int = 1920,
30 |             val screenHeightPoints: Int = 362,
31 |             val screenPixelDensity: Int = 1,
32 |             val utcOffsetMinutes: Int = 180,
33 |             val userInterfaceTheme: String = "USER_INTERFACE_THEME_LIGHT"
34 |     )
35 | 
36 |     data class Request(
37 |             val internalExperimentFlags: ArrayNode = JsonNodeFactory.instance.arrayNode(),
38 |             val consistencyTokenJars: ArrayNode = JsonNodeFactory.instance.arrayNode()
39 |     )
40 | 
41 | }
42 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/LiveChatRequestParameters.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 | 
3 | data class LiveChatRequestParameters(
4 |         val videoId: String,
5 |         val channelName: String,
6 |         val innertubeApiKey: String,
7 |         val nextContinuation: String
8 | )
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/MetadataResponse.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | data class MetadataResponse(
 4 |         val actions: List
 5 | ) {
 6 | 
 7 |     data class Action(
 8 |             val updateViewershipAction: UpdateViewershipAction?
 9 |     )
10 | 
11 |     data class UpdateViewershipAction(
12 |             val viewCount: ViewCount
13 |     )
14 | 
15 |     data class ViewCount(
16 |             val videoViewCountRenderer: VideoViewCountRenderer
17 |     )
18 | 
19 |     data class VideoViewCountRenderer(
20 |             val viewCount: ViewCountRendered? // is null on 0 viewers
21 |     )
22 | 
23 |     data class ViewCountRendered(
24 |             val simpleText: String
25 |     )
26 | 
27 | }
28 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/RoleBadges.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.chat.ImageFormat
 4 | import failchat.chat.badge.ImageBadge
 5 | 
 6 | object RoleBadges {
 7 |     val verified = ImageBadge("../_shared/icons/youtube-verified.svg", ImageFormat.VECTOR, "Verified")
 8 |     val streamer = ImageBadge("../_shared/icons/youtube-streamer.svg", ImageFormat.VECTOR, "Streamer")
 9 |     val moderator = ImageBadge("../_shared/icons/youtube-moderator.svg", ImageFormat.VECTOR, "Moderator")
10 | }
11 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/StreamStatus.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 | 
3 | enum class StreamStatus {
4 |     NOT_STARTED,
5 |     ONLINE,
6 |     ENDED,
7 |     NOT_FOUND
8 | }
9 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/UpdatedMetadataRequest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | data class UpdatedMetadataRequest(
 4 |         val context: Context,
 5 |         val videoId: String
 6 | ) {
 7 | 
 8 |     data class Context(
 9 |             val client: Client
10 |     )
11 | 
12 |     data class Client(
13 |             val clientName: String,
14 |             val clientVersion: String
15 |     )
16 | 
17 | }
18 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeClientException.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 | 
3 | class YoutubeClientException(
4 |         override val message: String? = null,
5 |         override val cause: Throwable? = null
6 | ) : RuntimeException()
7 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeColors.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import javafx.scene.paint.Color
 4 | 
 5 | object YoutubeColors {
 6 |     val streamer: Color = Color.web("#ffd600")
 7 |     val moderator: Color = Color.web("#5e84f1")
 8 |     val member: Color = Color.web("#107516")
 9 | }
10 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeEmoticon.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.Origin.YOUTUBE
 4 | import failchat.chat.ImageFormat
 5 | import failchat.emoticon.Emoticon
 6 | 
 7 | class YoutubeEmoticon(
 8 |         code: String,
 9 |         override val url: String,
10 |         format: ImageFormat
11 | ) : Emoticon(YOUTUBE, code, format)
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeHighlightHandler.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.chat.MessageHandler
 4 | import java.util.concurrent.atomic.AtomicReference
 5 | 
 6 | class YoutubeHighlightHandler : MessageHandler {
 7 | 
 8 |     private val appealedChannelTitle = AtomicReference()
 9 | 
10 |     override fun handleMessage(message: YoutubeMessage) {
11 |         appealedChannelTitle.get()?.let {
12 |             if (message.text.contains(it)) {
13 |                 message.highlighted = true
14 |             }
15 |         }
16 |     }
17 | 
18 |     fun setChannelTitle(channelTitle: String) {
19 |         appealedChannelTitle.set("@$channelTitle")
20 |     }
21 | }
22 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeMessage.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.Author
 5 | import failchat.chat.ChatMessage
 6 | 
 7 | class YoutubeMessage(
 8 |         failchatId: Long,
 9 |         author: Author,
10 |         text: String
11 | ) : ChatMessage(failchatId, Origin.YOUTUBE, author, text)
12 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeViewCountParser.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import java.util.regex.Pattern
 4 | 
 5 | class YoutubeViewCountParser {
 6 | 
 7 |     private companion object {
 8 |         val viewCountPattern = Pattern.compile("""(.+) watching now""")!!
 9 |     }
10 | 
11 |     /**
12 |      * @throws IllegalArgumentException on parse error.
13 |      * */
14 |     fun parse(viewCount: String): Int {
15 |         val m = viewCountPattern.matcher(viewCount)
16 |         require(m.matches()) { "Unexpected view count string: $viewCount" }
17 | 
18 |         val countWithCommas = m.group(1)
19 |         return countWithCommas.replace(",", "").toInt()
20 |     }
21 | 
22 | }
23 | 


--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeViewersCountLoader.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.Origin
 4 | import failchat.util.LateinitVal
 5 | import failchat.viewers.ViewersCountLoader
 6 | import kotlinx.coroutines.CoroutineScope
 7 | import kotlinx.coroutines.Dispatchers
 8 | import kotlinx.coroutines.future.future
 9 | import java.util.concurrent.CompletableFuture
10 | 
11 | class YoutubeViewersCountLoader(
12 |         private val videoId: String,
13 |         private val youtubeClient: YoutubeClient
14 | ) : ViewersCountLoader {
15 | 
16 |     private val lazyInnertubeApiKey = LateinitVal()
17 | 
18 |     override val origin = Origin.YOUTUBE
19 | 
20 |     override fun loadViewersCount(): CompletableFuture {
21 |         return CoroutineScope(Dispatchers.Default).future {
22 |             val key = lazyInnertubeApiKey.get() ?: run {
23 |                 val newKey = youtubeClient.getNewLiveChatSessionData(videoId).innertubeApiKey
24 |                 lazyInnertubeApiKey.set(newKey)
25 |                 newKey
26 |             }
27 | 
28 |             youtubeClient.getViewersCount(videoId, key)
29 |         }
30 |     }
31 | }
32 | 


--------------------------------------------------------------------------------
/src/main/resources/config/default.properties:
--------------------------------------------------------------------------------
 1 | version=${project.version}
 2 | # Messaging
 3 | lastMessageId=0
 4 | # Update checker
 5 | release-checker.enabled=true
 6 | release-checker.latest-notified-version=v${project.version}
 7 | github.api-url=https://api.github.com/
 8 | github.user-name=onoderis
 9 | github.repository=failchat
10 | # User configuration
11 | goodgame.enabled=false
12 | goodgame.channel=
13 | twitch.enabled=false
14 | twitch.channel=
15 | youtube.enabled=false
16 | youtube.channel=
17 | skin=old_sc2tv
18 | frame=true
19 | on-top=false
20 | show-viewers=false
21 | show-images=false
22 | click-transparency=false
23 | opacity=100
24 | show-origin-badges=true
25 | show-user-badges=true
26 | zoom-percent=100
27 | hide-deleted-messages=false
28 | deleted-message-placeholder=message deleted
29 | show-click-transparency-icon=true
30 | save-message-history=false
31 | native-client.background-color=#000000ff
32 | native-client.hide-messages=false
33 | native-client.colored-nicknames=true
34 | native-client.hide-messages-after=60
35 | native-client.show-status-messages=true
36 | external-client.background-color=#00000000
37 | external-client.colored-nicknames=true
38 | external-client.hide-messages=false
39 | external-client.hide-messages-after=60
40 | external-client.show-status-messages=true
41 | show-hidden-messages=false
42 | reset-configuration=false
43 | # Window positioning
44 | chat.width=350
45 | chat.height=500
46 | chat.x=-1
47 | chat.y=-1
48 | # Gui mode
49 | gui-mode=FULL_GUI
50 | # Urls, etc
51 | twitch.irc-address=irc.chat.twitch.tv
52 | twitch.irc-port=6697
53 | bttv.api-url=https://api.betterttv.net/
54 | frankerfacez.api-url=https://api.frankerfacez.com/v1/
55 | goodgame.ws-url=wss://chat-1.goodgame.ru/chat2/
56 | goodgame.api-url=https://goodgame.ru/api/
57 | goodgame.emoticon-js-url=https://goodgame.ru/js/minified/global.js
58 | goodgame.badge-url=https://static.goodgame.ru/files/icons/
59 | # fx
60 | about.github-repo=https://github.com/onoderis/failchat
61 | about.discord-server=https://discord.gg/4Qs3Y8h
62 | 


--------------------------------------------------------------------------------
/src/main/resources/icons/click-transparency-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/click-transparency-mode.png


--------------------------------------------------------------------------------
/src/main/resources/icons/failchat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/failchat.png


--------------------------------------------------------------------------------
/src/main/resources/icons/funstream.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/funstream.png


--------------------------------------------------------------------------------
/src/main/resources/icons/goodgame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/goodgame.png


--------------------------------------------------------------------------------
/src/main/resources/icons/twitch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/twitch.png


--------------------------------------------------------------------------------
/src/main/resources/icons/youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/youtube.png


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Configs.kt:
--------------------------------------------------------------------------------
 1 | package failchat
 2 | 
 3 | import org.apache.commons.configuration2.Configuration
 4 | import org.apache.commons.configuration2.PropertiesConfiguration
 5 | import org.apache.commons.configuration2.builder.FileBasedConfigurationBuilder
 6 | import org.apache.commons.configuration2.builder.fluent.Parameters
 7 | 
 8 | object Configs
 9 | 
10 | fun loadDefaultConfig() = loadConfig("/config/default.properties")
11 | 
12 | private fun loadConfig(resource: String): Configuration {
13 |     return FileBasedConfigurationBuilder(PropertiesConfiguration::class.java)
14 |             .configure(
15 |                     Parameters()
16 |                             .properties()
17 |                             .setURL(Configs.javaClass.getResource(resource))
18 |                             .setThrowExceptionOnMissing(true)
19 |             )
20 |             .configuration
21 | }
22 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Shared.kt:
--------------------------------------------------------------------------------
 1 | package failchat
 2 | 
 3 | import com.fasterxml.jackson.databind.ObjectMapper
 4 | import failchat.util.objectMapper
 5 | import io.ktor.client.HttpClient
 6 | import io.ktor.client.engine.okhttp.OkHttp
 7 | import okhttp3.OkHttpClient
 8 | import org.apache.commons.configuration2.Configuration
 9 | 
10 | val okHttpClient: OkHttpClient = OkHttpClient.Builder()
11 | //        .addInterceptor(HttpLoggingInterceptor().also { it.level = HttpLoggingInterceptor.Level.BODY })
12 |         .build()
13 | 
14 | val ktorClient = HttpClient(OkHttp) {
15 |     engine {
16 |         preconfigured = okHttpClient
17 |     }
18 | }
19 | 
20 | 
21 | val userHomeConfig: Configuration by lazy { ConfigLoader(failchatHomePath).load() }
22 | val defaultConfig: Configuration by lazy { loadDefaultConfig() }
23 | val testObjectMapper: ObjectMapper = objectMapper()
24 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Utils.kt:
--------------------------------------------------------------------------------
 1 | package failchat
 2 | 
 3 | import failchat.util.await
 4 | import okhttp3.Request
 5 | import kotlin.test.assertEquals
 6 | 
 7 | object Utils
 8 | 
 9 | fun readResourceAsString(resource: String): String {
10 |     val bytes = Utils::class.java.getResourceAsStream(resource)?.readBytes() ?: error("No resource $resource")
11 |     return String(bytes)
12 | }
13 | 
14 | suspend fun assertRequestToUrlReturns200(url: String) {
15 |     val request = Request.Builder().url(url).get().build()
16 |     okHttpClient.newCall(request).await().use {
17 |         assertEquals(200, it.code)
18 |     }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/chat/handlers/EmojiHandlerTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.chat.handlers
 2 | 
 3 | import failchat.Origin
 4 | import failchat.chat.Author
 5 | import failchat.emoticon.Emoticon
 6 | import failchat.okHttpClient
 7 | import failchat.youtube.YoutubeMessage
 8 | import okhttp3.Request
 9 | import org.junit.Test
10 | import kotlin.test.assertEquals
11 | 
12 | class EmojiHandlerTest {
13 | 
14 |     private val emojiHandler = EmojiHandler()
15 | 
16 |     @Test
17 |     fun oneCharacterEmojiTest() = testYtEmojiHandler("""☕""")
18 | 
19 |     @Test
20 |     fun twoCharacterEmojiTest() = testYtEmojiHandler("""😀""")
21 | 
22 |     @Test
23 |     fun threeCharacterEmojiTest() = testYtEmojiHandler("☝\uD83C\uDFFD")
24 | 
25 |     @Test
26 |     fun fourCharacterEmojiTest() = testYtEmojiHandler("👦\uD83C\uDFFD")
27 | 
28 |     @Test
29 |     fun flagTest() = testYtEmojiHandler("🇦🇩")
30 | 
31 |     private fun testYtEmojiHandler(text: String) {
32 |         val message = YoutubeMessage(0, Author("author", Origin.YOUTUBE, "aid"), text)
33 | 
34 |         emojiHandler.handleMessage(message)
35 | 
36 |         assertEmojiFound(message)
37 |     }
38 | 
39 |     private fun assertEmojiFound(message: YoutubeMessage) {
40 |         val request = Request.Builder()
41 |                 .get()
42 |                 .url((message.elements[0] as Emoticon).url)
43 |                 .build()
44 | 
45 |         okHttpClient.newCall(request).execute().use { response ->
46 |             assertEquals(200, response.code)
47 |         }
48 |     }
49 | 
50 | }
51 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/emoticon/FailchatEmoticonScannerTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.emoticon
 2 | 
 3 | import java.nio.file.Path
 4 | import java.nio.file.Paths
 5 | import kotlin.test.Test
 6 | import kotlin.test.assertNotNull
 7 | import kotlin.test.assertNull
 8 | 
 9 | class FailchatEmoticonScannerTest {
10 | 
11 |     private companion object {
12 |         val testDirPath: Path = Paths.get(FailchatEmoticonScannerTest::class.java.getResource("/failchat-emoticons").toURI())
13 |         val failchatEmoticonScanner = FailchatEmoticonScanner(testDirPath, "/")
14 |     }
15 | 
16 |     private val scanResult = failchatEmoticonScanner.scan()
17 | 
18 |     @Test
19 |     fun scanJpg(){
20 |         assertNotNull(scanResult.find { it.code == "1" })
21 |     }
22 | 
23 |     @Test
24 |     fun scanJpeg(){
25 |         assertNotNull(scanResult.find { it.code == "2" })
26 |     }
27 | 
28 |     @Test
29 |     fun scanPng(){
30 |         assertNotNull(scanResult.find { it.code == "3" })
31 |     }
32 | 
33 |     @Test
34 |     fun scanSvg(){
35 |         assertNotNull(scanResult.find { it.code == "4" })
36 |     }
37 | 
38 |     @Test
39 |     fun scanGif(){
40 |         assertNotNull(scanResult.find { it.code == "5" })
41 |     }
42 | 
43 |     @Test
44 |     fun ignoreUnknownFormat() {
45 |         assertNull(scanResult.find { it.code == "unknown-format" })
46 |     }
47 | 
48 |     @Test
49 |     fun ignoreUnsupportedFormat() {
50 |         assertNull(scanResult.find { it.code == "unsupported-format" })
51 |     }
52 | 
53 |     @Test
54 |     fun acceptMultipleDotsInName() {
55 |         assertNotNull(scanResult.find { it.code == "so.many.dots" })
56 |     }
57 | 
58 |     @Test
59 |     fun acceptUnderscoreInName() {
60 |         assertNotNull(scanResult.find { it.code == "underscore_" })
61 |     }
62 | 
63 |     @Test
64 |     fun acceptMinusInName() {
65 |         assertNotNull(scanResult.find { it.code == "minus-" })
66 |     }
67 | 
68 |     @Test
69 |     fun upperCase() {
70 |         assertNotNull(scanResult.find { it.code == "UPPERCASE" })
71 |     }
72 | 
73 | }
74 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/emoticon/WordReplacerTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.emoticon
 2 | 
 3 | import org.junit.Test
 4 | import kotlin.test.assertEquals
 5 | import kotlin.test.assertSame
 6 | 
 7 | class WordReplacerTest {
 8 | 
 9 |     @Test
10 |     fun replaceNothingTest() {
11 |         val words = listOf("just", "a", "simple", "message")
12 |         val initialString = words.joinToString(separator = " ")
13 | 
14 |         val actualWords = ArrayList()
15 |         val resultString = WordReplacer.replace(initialString) {
16 |             actualWords.add(it)
17 |             ReplaceDecision.Skip
18 |         }
19 | 
20 |         assertSame(initialString, resultString)
21 |         assertEquals(words, actualWords)
22 |     }
23 | 
24 |     @Test
25 |     fun replaceAllTest() {
26 |         val resultString = WordReplacer.replace("just a simple message") {
27 |             ReplaceDecision.Replace("42")
28 |         }
29 | 
30 |         assertEquals("42 42 42 42", resultString)
31 |     }
32 | 
33 |     @Test
34 |     fun specialCharactersTest() {
35 |         val words = listOf(
36 |                 "!", "@", "#", "$", "%", "^", "&", "*", "(", ")",
37 |                 "!!", "@@", "##", "$$", "%%", "^^", "&&", "**", "((", "))"
38 |         )
39 |         val initialString = words.joinToString(separator = " ")
40 | 
41 |         val resultString = WordReplacer.replace(initialString) {
42 |             ReplaceDecision.Replace("1")
43 |         }
44 | 
45 |         assertEquals(words.joinToString(separator = " ") { "1" }, resultString)
46 |     }
47 | 
48 |     @Test
49 |     fun lineBreakTest() {
50 |         val resultString = WordReplacer.replace("the\nmessage") {
51 |             ReplaceDecision.Replace("1")
52 |         }
53 | 
54 |         assertEquals("1\n1", resultString)
55 |     }
56 | 
57 | }
58 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/experiment/EmojiLibTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.experiment
 2 | 
 3 | import com.vdurmont.emoji.EmojiParser
 4 | import org.junit.Ignore
 5 | import org.junit.Test
 6 | 
 7 | @Ignore
 8 | class EmojiLibTest {
 9 | 
10 |     @Test
11 |     fun test() {
12 |         /*
13 |         * ☕ -> 2615
14 |         * 😀 -> 1f600
15 |         * ☝🏽 -> 261d-1f3fd
16 |         * 👦🏽 -> 1f466-1f3fd
17 |         * todo 🧘‍♀
18 |         * */
19 | 
20 |         EmojiParser.parseFromUnicode("\uD83D\uDE00") {
21 |             println(it.emoji.unicode)
22 |             "<>"
23 |         }
24 | 
25 |     }
26 | 
27 | }
28 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/experiment/OkHttpWsClient.kt:
--------------------------------------------------------------------------------
 1 | package failchat.experiment
 2 | 
 3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
 4 | import failchat.util.sleep
 5 | import okhttp3.OkHttpClient
 6 | import okhttp3.Request
 7 | import okhttp3.Response
 8 | import okhttp3.WebSocket
 9 | import okhttp3.WebSocketListener
10 | import okio.ByteString
11 | import org.junit.Ignore
12 | import org.junit.Test
13 | import java.time.Duration
14 | 
15 | @Ignore
16 | class OkHttpWsClient {
17 | 
18 |     @Test
19 |     fun tryIt() {
20 |         val client = OkHttpClient.Builder()
21 |                 .retryOnConnectionFailure(true)
22 |                 .build()
23 | 
24 |         val reuqest = Request.Builder()
25 |                 .url("ws://chat.goodgame.ru:8081/chat/websocket")
26 |                 .build()
27 | 
28 |         client.newWebSocket(reuqest, Listener())
29 | 
30 |         sleep(Duration.ofDays(1))
31 |     }
32 | 
33 |     private class Listener : WebSocketListener() {
34 |         override fun onOpen(webSocket: WebSocket, response: Response) {
35 |             println("onOpen")
36 | 
37 |             val joinMessage = JsonNodeFactory.instance.objectNode().apply {
38 |                 put("type", "join")
39 |                 putObject("data").apply {
40 |                     put("channel_id", 20296)
41 |                     put("isHidden", false)
42 |                 }
43 |             }
44 | 
45 |             webSocket.send(joinMessage.toString())
46 |         }
47 | 
48 |         override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = response.use { println("onFailure $t") }
49 | 
50 |         override fun onClosing(webSocket: WebSocket, code: Int, reason: String) = println("onClosing $code, $reason")
51 | 
52 |         override fun onMessage(webSocket: WebSocket, text: String) = println("onMessage: $text")
53 | 
54 |         override fun onMessage(webSocket: WebSocket, bytes: ByteString) = println("onMessage bytes")
55 | 
56 |         override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = println("onClosed $code $reason")
57 |     }
58 | 
59 | }
60 | /*
61 | {"type":"channel_counters","data":{"channel_id":"20296","clients_in_channel":"3","users_in_channel":1}}
62 | * */
63 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/github/GithubClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.github
 2 | 
 3 | import failchat.defaultConfig
 4 | import failchat.okHttpClient
 5 | import failchat.testObjectMapper
 6 | import org.junit.Test
 7 | 
 8 | class GithubClientTest {
 9 | 
10 |     private val githubClient = GithubClient(defaultConfig.getString("github.api-url"), okHttpClient, testObjectMapper)
11 | 
12 |     @Test
13 |     fun lastReleaseTest() {
14 |         githubClient.requestLatestRelease(
15 |                 defaultConfig.getString("github.user-name"),
16 |                 defaultConfig.getString("github.repository")
17 |         )
18 |                 .join()
19 |     }
20 | 
21 | }
22 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/goodgame/GgApi2ClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.exception.ChannelOfflineException
 4 | import failchat.okHttpClient
 5 | import failchat.testObjectMapper
 6 | import kotlinx.coroutines.runBlocking
 7 | import mu.KotlinLogging
 8 | import kotlin.test.Test
 9 | 
10 | class GgApi2ClientTest {
11 | 
12 |     private companion object {
13 |         val logger = KotlinLogging.logger {}
14 |     }
15 | 
16 |     private val client = GgApi2Client(okHttpClient, testObjectMapper)
17 | 
18 |     @Test
19 |     fun requestViewersCountTest() = runBlocking {
20 |         try {
21 |             val count = client.requestViewersCount("Fotos")
22 |             logger.debug("gg viewers count: {}", count)
23 |         } catch (ignored: ChannelOfflineException) {
24 |             logger.debug("gg channel is offline")
25 |         }
26 |     }
27 | 
28 | }
29 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/goodgame/GgApiClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.goodgame
 2 | 
 3 | import failchat.defaultConfig
 4 | import failchat.okHttpClient
 5 | import failchat.testObjectMapper
 6 | import kotlinx.coroutines.runBlocking
 7 | import org.junit.Test
 8 | import org.slf4j.Logger
 9 | import org.slf4j.LoggerFactory
10 | import kotlin.system.measureTimeMillis
11 | 
12 | class GgApiClientTest {
13 | 
14 |     private companion object {
15 |         val log: Logger = LoggerFactory.getLogger(GgApiClientTest::class.java)
16 |     }
17 | 
18 |     private val apiClient = GgApiClient(
19 |             okHttpClient,
20 |             testObjectMapper,
21 |             defaultConfig.getString("goodgame.api-url"),
22 |             defaultConfig.getString("goodgame.emoticon-js-url")
23 |     )
24 | 
25 |     @Test
26 |     fun channelIdTest() = runBlocking {
27 |         apiClient.requestChannelId("Miker")
28 |     }
29 | 
30 |     @Test
31 |     fun emoticonsRequestTest() = runBlocking {
32 |         val t = measureTimeMillis {
33 |             val emoticons = apiClient.requestEmoticonList()
34 |             log.debug("gg emoticons size: {}", emoticons.size)
35 |         }
36 |         log.debug("gg emoticons load time: {} ms", t)
37 |     }
38 | 
39 |     @Test
40 |     fun requestChannelInfoTest() = runBlocking {
41 |         val c = apiClient.requestChannelInfo("Miker")
42 |         log.debug("Channel info: {}", c)
43 |     }
44 | 
45 | }
46 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/gui/LoadingPopupTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import javafx.application.Application
 4 | import javafx.stage.Popup
 5 | import javafx.stage.Stage
 6 | 
 7 | class App : Application() {
 8 |     override fun start(primaryStage: Stage) {
 9 |         val loadingPopup = Popup()
10 |         loadingPopup.scene
11 | 
12 | 
13 | 
14 |         loadingPopup.show(primaryStage)
15 |         primaryStage.show()
16 | 
17 |     }
18 | 
19 | }
20 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/gui/SkinConverterTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.gui
 2 | 
 3 | import failchat.skin.Skin
 4 | import org.junit.Test
 5 | import java.nio.file.Paths
 6 | import kotlin.test.assertEquals
 7 | 
 8 | class SkinConverterTest {
 9 | 
10 |     private val skinOne = Skin("one", Paths.get("/skins/one.html"))
11 | 
12 |     private val skinConverter = SkinConverter(listOf(
13 |             skinOne,
14 |             Skin("two", Paths.get("/skins/two.html"))
15 |     ))
16 | 
17 |     @Test
18 |     fun defaultSkinTest() {
19 |         val s = skinConverter.fromString("three")
20 | 
21 |         assertEquals(skinOne, s)
22 |     }
23 | 
24 | }


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/BttvApiClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.okHttpClient
 4 | import failchat.testObjectMapper
 5 | import kotlinx.coroutines.runBlocking
 6 | import org.junit.Assert.assertTrue
 7 | import org.junit.Test
 8 | 
 9 | class BttvApiClientTest {
10 | 
11 |     private val client = BttvApiClient(
12 |         httpClient = okHttpClient,
13 |         apiUrl = "https://api.betterttv.net/",
14 |         objectMapper = testObjectMapper,
15 |     )
16 | 
17 |     @Test
18 |     fun loadGlobalEmoticons() = runBlocking {
19 |         val emoticons = client.loadGlobalEmoticons().join()
20 |         assertTrue(emoticons.isNotEmpty())
21 |     }
22 | 
23 |     @Test
24 |     fun loadChannelEmoticons() = runBlocking {
25 |         val emoticons = client.loadChannelEmoticons("lirik").join()
26 |         assertTrue(emoticons.isNotEmpty())
27 |     }
28 | }
29 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/FfzApiClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.ConfigKeys
 4 | import failchat.defaultConfig
 5 | import failchat.okHttpClient
 6 | import failchat.testObjectMapper
 7 | import kotlinx.coroutines.runBlocking
 8 | import mu.KotlinLogging
 9 | import org.junit.Test
10 | 
11 | class FfzApiClientTest {
12 | 
13 |     private companion object {
14 |         val logger = KotlinLogging.logger {}
15 |     }
16 | 
17 |     private val apiClient = FfzApiClient(
18 |             okHttpClient,
19 |             testObjectMapper,
20 |             defaultConfig.getString(ConfigKeys.frankerfacezApiUrl)
21 |     )
22 |     private val roomName = "forsen"
23 | 
24 |     @Test
25 |     fun requestEmoticonsTest(): Unit = runBlocking {
26 |         val emoticons = apiClient.requestEmoticons(roomName)
27 |         logger.info("ffz emoticons for room {} - {}", roomName, emoticons.size)
28 |     }
29 | 
30 | }
31 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/SevenTvApiClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.assertRequestToUrlReturns200
 4 | import failchat.okHttpClient
 5 | import failchat.testObjectMapper
 6 | import io.kotest.assertions.throwables.shouldThrow
 7 | import kotlinx.coroutines.runBlocking
 8 | import mu.KotlinLogging
 9 | import org.junit.Test
10 | 
11 | class SevenTvApiClientTest {
12 | 
13 |     private companion object {
14 |         val logger = KotlinLogging.logger {}
15 |     }
16 | 
17 |     private val apiClient = SevenTvApiClient(
18 |             okHttpClient,
19 |             testObjectMapper
20 |     )
21 | 
22 |     @Test
23 |     fun loadGlobalEmoticons() = runBlocking {
24 |         val emoticons = apiClient.loadGlobalEmoticons()
25 |         logger.info("7tv global emoticons count: {}", emoticons.size)
26 | 
27 |         assertRequestToUrlReturns200(emoticons.first().url)
28 |     }
29 | 
30 |     @Test
31 |     fun loadChannelEmoticons() = runBlocking {
32 |         val emoticons = apiClient.loadChannelEmoticons(23161357L) // lirik
33 |         logger.info("7tv channel emoticons count: {}", emoticons.size)
34 | 
35 |         assertRequestToUrlReturns200(emoticons.first().url)
36 |     }
37 | 
38 |     @Test
39 |     fun channelNotFoundTest() = runBlocking {
40 |         shouldThrow {
41 |             apiClient.loadChannelEmoticons(123L)
42 |         }
43 |     }
44 | }
45 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/TwitchEmoticonHandlerTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.twitch
 2 | 
 3 | import failchat.chat.Elements
 4 | import io.kotest.matchers.shouldBe
 5 | import org.junit.Test
 6 | 
 7 | class TwitchEmoticonHandlerTest {
 8 | 
 9 |     private val handler = TwitchEmoticonHandler(TwitchEmotesTagParser())
10 | 
11 |     @Test
12 |     fun longEmoticonCodeTest() {
13 |         // Given
14 |         val message = TwitchMessage(
15 |                 id = 0,
16 |                 author = "",
17 |                 text = "Kappa 123 Kappa Keepo he",
18 |                 tags = mapOf(TwitchIrcTags.emotes to "25:0-4,10-14/1902:16-20")
19 |         )
20 | 
21 |         // When
22 |         handler.handleMessage(message)
23 | 
24 |         // Then
25 |         message.text shouldBe "${Elements.label(0)} 123 ${Elements.label(1)} ${Elements.label(2)} he"
26 |         message.elements.size shouldBe 3
27 |         (message.elements[0] as TwitchEmoticon).twitchId shouldBe "25"
28 |         (message.elements[0] as TwitchEmoticon).code shouldBe "Kappa"
29 |         (message.elements[1] as TwitchEmoticon).twitchId shouldBe "25"
30 |         (message.elements[1] as TwitchEmoticon).code shouldBe "Kappa"
31 |         (message.elements[2] as TwitchEmoticon).twitchId shouldBe "1902"
32 |         (message.elements[2] as TwitchEmoticon).code shouldBe "Keepo"
33 |     }
34 | 
35 |     @Test
36 |     fun noEmoticonsTest() {
37 |         // Given
38 |         val message = TwitchMessage(
39 |                 id = 0,
40 |                 author = "",
41 |                 text = "message",
42 |                 tags = mapOf(TwitchIrcTags.emotes to "")
43 |         )
44 |         // When
45 |         handler.handleMessage(message)
46 | 
47 |         // Then
48 |         message.elements.size shouldBe 0
49 |         message.text shouldBe "message"
50 |     }
51 | 
52 | }
53 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/util/StackTraceFormatTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.util
 2 | 
 3 | import org.junit.Ignore
 4 | import org.junit.Test
 5 | import org.slf4j.Logger
 6 | import org.slf4j.LoggerFactory
 7 | 
 8 | @Ignore
 9 | class StackTraceFormatTest {
10 | 
11 |     private companion object {
12 |         val log: Logger = LoggerFactory.getLogger(StackTraceFormatTest::class.java)
13 |     }
14 | 
15 |     @Test
16 |     fun manualTest() {
17 |         log.info(formatStackTraces(Thread.getAllStackTraces()))
18 |     }
19 | 
20 | }
21 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeClientTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.ktorClient
 4 | import failchat.testObjectMapper
 5 | import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
 6 | import kotlinx.coroutines.runBlocking
 7 | import org.junit.Ignore
 8 | import org.junit.Test
 9 | import kotlin.test.assertTrue
10 | 
11 | @Ignore
12 | class YoutubeClientTest {
13 | 
14 |     private val client = YoutubeClient(
15 |             httpClient = ktorClient,
16 |             objectMapper = testObjectMapper,
17 |             youtubeHtmlParser = YoutubeHtmlParser(objectMapper = testObjectMapper)
18 |     )
19 |     private val videoId = "jfKfPfyJRdk"
20 | 
21 |     @Test
22 |     fun getViewersCountTest() = runBlocking {
23 |         val innertubeApiKey = client.getNewLiveChatSessionData(videoId).innertubeApiKey
24 | 
25 |         val count = client.getViewersCount(videoId, innertubeApiKey)
26 | 
27 |         count shouldBeGreaterThanOrEqual 0
28 |         println(count)
29 |     }
30 | 
31 |     @Test
32 |     fun getMessagesTest() = runBlocking {
33 |         val params = client.getNewLiveChatSessionData(videoId)
34 |         val response = client.getLiveChatResponse(params)
35 | 
36 |         assertTrue(response.continuationContents.liveChatContinuation.actions.isNotEmpty())
37 |     }
38 | }
39 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeHtmlParserTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import failchat.readResourceAsString
 4 | import failchat.testObjectMapper
 5 | import io.kotest.matchers.shouldBe
 6 | import org.junit.Test
 7 | 
 8 | class YoutubeHtmlParserTest {
 9 | 
10 |     private val youtubeHtmlParser = YoutubeHtmlParser(testObjectMapper)
11 | 
12 |     @Test
13 |     fun `should extract innertubeApiKey`() {
14 |         // Given
15 |         val html = readResourceAsString("/html/live_chat.html")
16 | 
17 |         // When
18 |         val youtubeConfig = youtubeHtmlParser.parseYoutubeConfig(html)
19 |         val actual = youtubeHtmlParser.extractInnertubeApiKey(youtubeConfig)
20 | 
21 |         // Then
22 |         actual shouldBe "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
23 |     }
24 | 
25 |     @Test
26 |     fun `should extract initial continuation`() {
27 |         // Given
28 |         val html = readResourceAsString("/html/live_chat.html")
29 | 
30 |         // When
31 |         val initialData = youtubeHtmlParser.parseInitialData(html)
32 |         val actual = youtubeHtmlParser.extractInitialContinuation(initialData)
33 | 
34 |         // Then
35 |         actual shouldBe "0ofMyAOqARpeQ2lrcUp3b1lWVU5UU2pSbmExWkROazV5ZGtsSk9IVnRlblJtTUU5M0VnczFjV0Z3TldGUE5HazVRUm9UNnFqZHVRRU5DZ3MxY1dGd05XRlBOR2s1UVNBQ0tBRSUzRCivjJ_s9ebuAjAAOABAAUoVCAEQABgAIABQx6K47fXm7gJYA3gAULXAwuz15u4CWLTkrPfk4u4CggECCASIAQCgAd7Uue315u4C"
36 |     }
37 | 
38 |     @Test
39 |     fun `should extract channel name`() {
40 |         // Given
41 |         val html = readResourceAsString("/html/live_chat.html")
42 | 
43 |         // When
44 |         val initialData = youtubeHtmlParser.parseInitialData(html)
45 |         val actual = youtubeHtmlParser.extractChannelName(initialData)
46 | 
47 |         // Then
48 |         actual shouldBe "ChilledCow"
49 |     }
50 | 
51 | }
52 | 


--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeViewCountParserTest.kt:
--------------------------------------------------------------------------------
 1 | package failchat.youtube
 2 | 
 3 | import io.kotest.matchers.shouldBe
 4 | import org.junit.Test
 5 | 
 6 | class YoutubeViewCountParserTest {
 7 | 
 8 |     private val youtubeViewCountParser = YoutubeViewCountParser()
 9 | 
10 |     @Test
11 |     fun `should parse number less than 1000`() {
12 |         // Given
13 |         val viewCount = "1,232 watching now"
14 |         // When
15 |         val actual = youtubeViewCountParser.parse(viewCount)
16 | 
17 |         // Then
18 |         actual shouldBe 1232
19 |     }
20 | 
21 |     @Test
22 |     fun `should parse number greater or equals than 1000`() {
23 |         // Given
24 |         val viewCount = "609 watching now"
25 | 
26 |         // When
27 |         val actual = youtubeViewCountParser.parse(viewCount)
28 | 
29 |         // Then
30 |         actual shouldBe 609
31 |     }
32 | 
33 | }
34 | 


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/1.jpg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/2.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/2.jpeg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/3.png


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/4.svg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/4.svg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/5.gif


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/UPPERCASE.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/UPPERCASE.JPG


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/minus-.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/minus-.jpg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/so.many.dots.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/so.many.dots.jpg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/underscore_.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/underscore_.jpg


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/unknown-format:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/unknown-format


--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/unsupported-format.img:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/unsupported-format.img


--------------------------------------------------------------------------------
/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker:
--------------------------------------------------------------------------------
1 | mock-maker-inline
2 | 


--------------------------------------------------------------------------------