├── src ├── test │ ├── resources │ │ ├── failchat-emoticons │ │ │ ├── 1.jpg │ │ │ ├── 3.png │ │ │ ├── 4.svg │ │ │ ├── 5.gif │ │ │ ├── 2.jpeg │ │ │ ├── minus-.jpg │ │ │ ├── UPPERCASE.JPG │ │ │ ├── underscore_.jpg │ │ │ ├── unknown-format │ │ │ ├── so.many.dots.jpg │ │ │ └── unsupported-format.img │ │ └── mockito-extensions │ │ │ └── org.mockito.plugins.MockMaker │ └── kotlin │ │ └── failchat │ │ ├── gui │ │ ├── LoadingPopupTest.kt │ │ └── SkinConverterTest.kt │ │ ├── util │ │ └── StackTraceFormatTest.kt │ │ ├── experiment │ │ ├── EmojiLibTest.kt │ │ └── OkHttpWsClient.kt │ │ ├── Utils.kt │ │ ├── github │ │ └── GithubClientTest.kt │ │ ├── Shared.kt │ │ ├── goodgame │ │ ├── GgApi2ClientTest.kt │ │ └── GgApiClientTest.kt │ │ ├── Configs.kt │ │ ├── twitch │ │ ├── BttvApiClientTest.kt │ │ ├── FfzApiClientTest.kt │ │ ├── SevenTvApiClientTest.kt │ │ └── TwitchEmoticonHandlerTest.kt │ │ ├── youtube │ │ ├── YoutubeViewCountParserTest.kt │ │ ├── YoutubeClientTest.kt │ │ └── YoutubeHtmlParserTest.kt │ │ ├── chat │ │ └── handlers │ │ │ └── EmojiHandlerTest.kt │ │ └── emoticon │ │ ├── WordReplacerTest.kt │ │ └── FailchatEmoticonScannerTest.kt └── main │ ├── external-resources │ ├── skins │ │ ├── _shared │ │ │ ├── templates │ │ │ │ ├── link.tmpl.html │ │ │ │ ├── emoticon-raster.tmpl.html │ │ │ │ ├── emoticon-vector.tmpl.html │ │ │ │ ├── image.tmpl.html │ │ │ │ ├── origin-viewers-bar.tmpl.html │ │ │ │ ├── status-message.tmpl.html │ │ │ │ └── message.tmpl.html │ │ │ ├── font │ │ │ │ └── icomoon.woff │ │ │ └── icons │ │ │ │ ├── failchat.png │ │ │ │ ├── goodgame.png │ │ │ │ ├── twitch.png │ │ │ │ ├── youtube.png │ │ │ │ ├── youtube-streamer.svg │ │ │ │ ├── youtube-moderator.svg │ │ │ │ ├── youtube-verified.svg │ │ │ │ └── click-transparency-mode.svg │ │ ├── funstream │ │ │ ├── icons │ │ │ │ ├── twitch.png │ │ │ │ ├── failchat.png │ │ │ │ ├── goodgame.png │ │ │ │ └── youtube.png │ │ │ ├── funstream.css │ │ │ └── funstream.html │ │ ├── old_sc2tv │ │ │ ├── old_sc2tv.css │ │ │ └── old_sc2tv.html │ │ └── glass │ │ │ └── glass.html │ ├── failchat.bat │ ├── java-agents │ │ └── transparent-webview-patch.jar │ └── failchat.sh │ ├── resources │ ├── icons │ │ ├── twitch.png │ │ ├── failchat.png │ │ ├── goodgame.png │ │ ├── youtube.png │ │ ├── funstream.png │ │ └── click-transparency-mode.png │ └── config │ │ └── default.properties │ ├── kotlin │ └── failchat │ │ ├── AppState.kt │ │ ├── skin │ │ ├── Skins.kt │ │ ├── Skin.kt │ │ └── SkinScanner.kt │ │ ├── chat │ │ ├── Image.kt │ │ ├── MessageHandler.kt │ │ ├── MessageElement.kt │ │ ├── badge │ │ │ ├── BadgeFinder.kt │ │ │ ├── BadgeOrigin.kt │ │ │ ├── BadgeStorage.kt │ │ │ ├── Badge.kt │ │ │ └── BadgeManager.kt │ │ ├── ImageFormat.kt │ │ ├── OriginStatus.kt │ │ ├── StatusUpdate.kt │ │ ├── ChatClientStatus.kt │ │ ├── DeletedMessagePlaceholder.kt │ │ ├── MessageFilter.kt │ │ ├── ChatClientCallbacks.kt │ │ ├── Link.kt │ │ ├── AppConfiguration.kt │ │ ├── OnChatMessageDeletedCallback.kt │ │ ├── MessageIdGenerator.kt │ │ ├── OnStatusUpdateCallback.kt │ │ ├── ChatClient.kt │ │ ├── Author.kt │ │ ├── handlers │ │ │ ├── BraceEscaper.kt │ │ │ ├── ElementLabelEscaper.kt │ │ │ ├── CommaHighlightHandler.kt │ │ │ ├── LinkHandler.kt │ │ │ ├── OriginsStatusHandler.kt │ │ │ ├── SpaceSeparatedEmoticonHandler.kt │ │ │ ├── FailchatEmoticonHandler.kt │ │ │ ├── ImageLinkHandler.kt │ │ │ ├── ChatHistoryLogger.kt │ │ │ ├── EmojiHandler.kt │ │ │ └── IgnoreFilter.kt │ │ ├── Elements.kt │ │ ├── ChatMessageRemover.kt │ │ ├── OnChatMessageCallback.kt │ │ ├── OriginStatusManager.kt │ │ └── ChatMessage.kt │ │ ├── emoticon │ │ ├── EmoticonAndId.kt │ │ ├── EmoticonIdAndCode.kt │ │ ├── EmoticonFactory.kt │ │ ├── ReplaceDecision.kt │ │ ├── EmoticonIdExtractor.kt │ │ ├── EmojiEmoticon.kt │ │ ├── EmoticonLoader.kt │ │ ├── TwitchEmoticonFactory.kt │ │ ├── EmoticonLoadConfiguration.kt │ │ ├── FailchatEmoticon.kt │ │ ├── EmoticonLoadException.kt │ │ ├── Emoticon.kt │ │ ├── EmoticonFinder.kt │ │ ├── FailchatEmoticonUpdater.kt │ │ ├── OriginEmoticonStorage.kt │ │ ├── MapdbFactory.kt │ │ ├── EmptyEmoticonStorage.kt │ │ ├── GlobalEmoticonUpdater.kt │ │ ├── OriginEmoticonStorageFactory.kt │ │ ├── WordReplacer.kt │ │ ├── EmoticonCodeIdMemoryStorage.kt │ │ ├── EmoticonCodeMemoryStorage.kt │ │ ├── SemicolonCodeProcessor.kt │ │ ├── DeletedMessagePlaceholderFactory.kt │ │ ├── EmoticonStorage.kt │ │ ├── FailchatEmoticonScanner.kt │ │ ├── EmoticonCodeIdDbStorage.kt │ │ ├── EmoticonManager.kt │ │ └── EmoticonCodeIdDbCompactStorage.kt │ │ ├── gui │ │ ├── GuiMode.kt │ │ ├── GuiFrames.kt │ │ ├── Images.kt │ │ ├── ClickTransparencyConfigurator.kt │ │ ├── Extensions.kt │ │ ├── WebViewLogger.kt │ │ ├── GuiEventHandlerIf.kt │ │ ├── PortBindAlert.kt │ │ ├── SkinConverter.kt │ │ ├── ChatFrameLauncher.kt │ │ └── ChatGuiEventHandler.kt │ │ ├── twitch │ │ ├── RangedEmoticon.kt │ │ ├── FfzChannelNotFoundException.kt │ │ ├── InvalidTokenException.kt │ │ ├── BttvChannelNotFoundException.kt │ │ ├── SevenTvChannelNotFoundException.kt │ │ ├── TwitchBadgeId.kt │ │ ├── HelixTokenContainer.kt │ │ ├── HelixApiToken.kt │ │ ├── UsersResponse.kt │ │ ├── TwitchEmoticonUrlFactory.kt │ │ ├── EmotesResponse.kt │ │ ├── SevenTvChannelResponse.kt │ │ ├── AuthResponse.kt │ │ ├── FfzEmoticon.kt │ │ ├── BttvEmoticon.kt │ │ ├── TwitchIrcTags.kt │ │ ├── SevenTvEmoticon.kt │ │ ├── TwitchMessage.kt │ │ ├── BttvEmoticonIdExtractor.kt │ │ ├── TwitchRewardHandler.kt │ │ ├── StreamsResponse.kt │ │ ├── TwitchEmoticonIdExtractor.kt │ │ ├── SevenTvEmoticonIdExtractor.kt │ │ ├── TwitchEmoticon.kt │ │ ├── TwitchEmoticonLoadConfiguration.kt │ │ ├── TwitchHighlightByPointsHandler.kt │ │ ├── BttvGlobalEmoticonLoadConfiguration.kt │ │ ├── SevenTvGlobalEmoticonLoadConfiguration.kt │ │ ├── TwitchHighlightHandler.kt │ │ ├── TwitchAuthorColorHandler.kt │ │ ├── BttvGlobalEmoticonLoader.kt │ │ ├── BadgesResponse.kt │ │ ├── SevenTvEmoteSetResponse.kt │ │ ├── SevenTvGlobalEmoticonLoader.kt │ │ ├── TwitchViewersCountLoader.kt │ │ ├── TwitchGlobalEmoticonLoader.kt │ │ ├── FfzEmoticonHandler.kt │ │ ├── TwitchRetries.kt │ │ ├── BttvEmoticonHandler.kt │ │ ├── ConfigurationTokenContainer.kt │ │ ├── TwitchEmotesTagParser.kt │ │ ├── FfzApiClient.kt │ │ ├── TwitchEmoticonHandler.kt │ │ ├── TwitchBadgeHandler.kt │ │ └── TokenAwareTwitchApiClient.kt │ │ ├── util │ │ ├── Eithers.kt │ │ ├── Jackson.kt │ │ ├── Files.kt │ │ ├── Streams.kt │ │ ├── Systems.kt │ │ ├── Collections.kt │ │ ├── OkHttpLogger.kt │ │ ├── Config.kt │ │ ├── Urls.kt │ │ ├── Ints.kt │ │ ├── JavaFx.kt │ │ ├── Lang.kt │ │ ├── Strings.kt │ │ ├── LateinitVal.kt │ │ ├── Coroutines.kt │ │ ├── Threads.kt │ │ ├── Json.kt │ │ ├── Concurrent.kt │ │ ├── OkHttp.kt │ │ └── CompletableFutures.kt │ │ ├── youtube │ │ ├── StreamStatus.kt │ │ ├── YoutubeClientException.kt │ │ ├── LiveChatRequestParameters.kt │ │ ├── YoutubeColors.kt │ │ ├── YoutubeMessage.kt │ │ ├── YoutubeEmoticon.kt │ │ ├── UpdatedMetadataRequest.kt │ │ ├── RoleBadges.kt │ │ ├── YoutubeViewCountParser.kt │ │ ├── YoutubeHighlightHandler.kt │ │ ├── MetadataResponse.kt │ │ ├── YoutubeViewersCountLoader.kt │ │ └── LiveChatRequest.kt │ │ ├── goodgame │ │ ├── GgChannel.kt │ │ ├── StreamResponse.kt │ │ ├── GgAuthorColorHandler.kt │ │ ├── GgEmoticonLoadConfiguration.kt │ │ ├── GgEmoticonIdExtractor.kt │ │ ├── GgEmoticon.kt │ │ ├── GgMessage.kt │ │ ├── HtmlUrlCleaner.kt │ │ ├── GgEmoticonLoader.kt │ │ ├── GgViewersCountLoader.kt │ │ ├── GgColors.kt │ │ ├── GgEmoticonHandler.kt │ │ └── GgApi2Client.kt │ │ ├── github │ │ ├── Release.kt │ │ ├── NoReleasesFoundException.kt │ │ ├── Version.kt │ │ ├── ReleaseChecker.kt │ │ └── GithubClient.kt │ │ ├── ws │ │ └── server │ │ │ ├── WsMessageHandler.kt │ │ │ ├── ClientConfigurationWsHandler.kt │ │ │ ├── DeleteWsMessageHandler.kt │ │ │ ├── IgnoreWsMessageHandler.kt │ │ │ ├── InboundWsMessage.kt │ │ │ └── WsMessageDispatcher.kt │ │ ├── exception │ │ ├── ChannelOfflineException.kt │ │ ├── UnexpectedResponseCodeException.kt │ │ ├── UnexpectedWsMessage.kt │ │ ├── NativeCallException.kt │ │ ├── ChannelNotFoundException.kt │ │ ├── DataNotFoundException.kt │ │ ├── InvalidConfigurationException.kt │ │ └── UnexpectedResponseException.kt │ │ ├── FailchatServerInfo.kt │ │ ├── viewers │ │ ├── ViewersCountLoader.kt │ │ ├── CountableOrigins.kt │ │ └── ViewersCountWsHandler.kt │ │ ├── ConfigUtils.kt │ │ ├── Origin.kt │ │ └── platform │ │ └── windows │ │ └── WindowsCtConfigurator.kt │ └── assembly │ └── zip.xml ├── .gitattributes ├── run.sh ├── .gitignore ├── .idea └── runConfigurations │ ├── glass_html.xml │ ├── funstream_html.xml │ ├── old_sc2tv_html.xml │ ├── failchat_dev.xml │ └── failchat_dev__chat_only_.xml ├── generate-jdeps-args.sh └── README.md /src/test/resources/failchat-emoticons/1.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/3.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/4.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/5.gif: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/2.jpeg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/minus-.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/UPPERCASE.JPG: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/underscore_.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/unknown-format: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | src/test/resources/** linguist-vendored 2 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/so.many.dots.jpg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/test/resources/failchat-emoticons/unsupported-format.img: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mvn compile org.codehaus.mojo:exec-maven-plugin:exec@run-app 3 | -------------------------------------------------------------------------------- /src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker: -------------------------------------------------------------------------------- 1 | mock-maker-inline 2 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/templates/link.tmpl.html: -------------------------------------------------------------------------------- 1 | {{:domain}} 2 | -------------------------------------------------------------------------------- /src/main/resources/icons/twitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/twitch.png -------------------------------------------------------------------------------- /src/main/kotlin/failchat/AppState.kt: -------------------------------------------------------------------------------- 1 | package failchat 2 | 3 | enum class AppState { 4 | SETTINGS, 5 | CHAT 6 | } 7 | -------------------------------------------------------------------------------- /src/main/resources/icons/failchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/failchat.png -------------------------------------------------------------------------------- /src/main/resources/icons/goodgame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/goodgame.png -------------------------------------------------------------------------------- /src/main/resources/icons/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/youtube.png -------------------------------------------------------------------------------- /src/main/resources/icons/funstream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/funstream.png -------------------------------------------------------------------------------- /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/chat/Image.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | data class Image( 4 | val link: Link 5 | ) : MessageElement 6 | 7 | -------------------------------------------------------------------------------- /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/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/emoticon/EmoticonIdAndCode.kt: -------------------------------------------------------------------------------- 1 | package failchat.emoticon 2 | 3 | data class EmoticonIdAndCode(val id: String, val code: String) 4 | -------------------------------------------------------------------------------- /src/main/resources/icons/click-transparency-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/resources/icons/click-transparency-mode.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/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/FfzChannelNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package failchat.twitch 2 | 3 | class FfzChannelNotFoundException(val channel: String) : Exception() 4 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/twitch/InvalidTokenException.kt: -------------------------------------------------------------------------------- 1 | package failchat.twitch 2 | 3 | class InvalidTokenException : RuntimeException("Invalid twitch token") 4 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/font/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/_shared/font/icomoon.woff -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/failchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/_shared/icons/failchat.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/goodgame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/_shared/icons/goodgame.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/twitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/_shared/icons/twitch.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/_shared/icons/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/_shared/icons/youtube.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/twitch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/funstream/icons/twitch.png -------------------------------------------------------------------------------- /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/kotlin/failchat/chat/MessageHandler.kt: -------------------------------------------------------------------------------- 1 | package failchat.chat 2 | 3 | interface MessageHandler { 4 | fun handleMessage(message: T) 5 | } 6 | -------------------------------------------------------------------------------- /src/main/kotlin/failchat/twitch/BttvChannelNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package failchat.twitch 2 | 3 | class BttvChannelNotFoundException(val channel: String) : Exception() 4 | -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/failchat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/funstream/icons/failchat.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/goodgame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/funstream/icons/goodgame.png -------------------------------------------------------------------------------- /src/main/external-resources/skins/funstream/icons/youtube.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/skins/funstream/icons/youtube.png -------------------------------------------------------------------------------- /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/TwitchBadgeId.kt: -------------------------------------------------------------------------------- 1 | package failchat.twitch 2 | 3 | data class TwitchBadgeId( 4 | val setId: String, 5 | val version: String 6 | ) -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/java-agents/transparent-webview-patch.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onoderis/failchat/HEAD/src/main/external-resources/java-agents/transparent-webview-patch.jar -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/external-resources/skins/_shared/templates/origin-viewers-bar.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/external-resources/skins/_shared/icons/youtube-streamer.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /.idea/runConfigurations/glass_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /.idea/runConfigurations/funstream_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.idea/runConfigurations/old_sc2tv_html.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/external-resources/skins/_shared/templates/status-message.tmpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | {{:origin}} 7 | {{:status}} 8 |
9 |
10 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/external-resources/skins/_shared/icons/youtube-moderator.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/external-resources/skins/_shared/icons/youtube-verified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /.idea/runConfigurations/failchat_dev.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.idea/runConfigurations/failchat_dev__chat_only_.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/main/external-resources/skins/_shared/icons/click-transparency-mode.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/EmoticonCodeIdDbCompactStorage.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 java.io.Closeable 11 | 12 | /** Search by code is case insensitive. */ 13 | class EmoticonCodeIdDbCompactStorage( 14 | db: DB, 15 | override val origin: Origin, 16 | private val emoticonFactory: EmoticonFactory 17 | ) : OriginEmoticonStorage, Closeable { 18 | 19 | private val lowerCaseCodeToId: HTreeMap 20 | private val idToNormalCaseCode: HTreeMap 21 | 22 | init { 23 | lowerCaseCodeToId = db 24 | .hashMap(origin.commonName + "-lowerCaseCodeToId", Serializer.STRING, Serializer.STRING) 25 | .createOrOpen() 26 | idToNormalCaseCode = db 27 | .hashMap(origin.commonName + "-idToNormalCaseCode", Serializer.STRING, Serializer.STRING) 28 | .createOrOpen() 29 | } 30 | 31 | override fun findByCode(code: String): Emoticon? { 32 | val id = lowerCaseCodeToId.get(code.lowercase()) ?: return null 33 | val normalCaseCode = idToNormalCaseCode.get(id) ?: return null 34 | return emoticonFactory.create(id, normalCaseCode) 35 | } 36 | 37 | override fun findById(id: String): Emoticon? { 38 | val normalCaseCode = idToNormalCaseCode.get(id) ?: return null 39 | return emoticonFactory.create(id, normalCaseCode) 40 | } 41 | 42 | override fun getAll(): Collection { 43 | throw NotImplementedError() 44 | } 45 | 46 | override fun count(): Int { 47 | return idToNormalCaseCode.size 48 | } 49 | 50 | override fun putAll(emoticons: Collection) { 51 | emoticons.forEach { 52 | putEmoticon(it) 53 | } 54 | } 55 | 56 | override fun putAll(emoticons: Flow) { 57 | runBlocking { 58 | emoticons.collect { 59 | putEmoticon(it) 60 | } 61 | } 62 | } 63 | 64 | private fun putEmoticon(emoticonAndId: EmoticonAndId) { 65 | idToNormalCaseCode.put(emoticonAndId.id, emoticonAndId.emoticon.code) 66 | lowerCaseCodeToId.putIfAbsent(emoticonAndId.emoticon.code.lowercase(), emoticonAndId.id) 67 | } 68 | 69 | override fun clear() { 70 | idToNormalCaseCode.clear() 71 | lowerCaseCodeToId.clear() 72 | } 73 | 74 | override fun close() { 75 | lowerCaseCodeToId.close() 76 | idToNormalCaseCode.close() 77 | } 78 | } 79 | --------------------------------------------------------------------------------