├── .gitattributes
├── .gitignore
├── .idea
└── runConfigurations
│ ├── failchat_dev.xml
│ ├── failchat_dev__chat_only_.xml
│ ├── funstream_html.xml
│ ├── glass_html.xml
│ └── old_sc2tv_html.xml
├── COPYING
├── README.md
├── build-runtime.sh
├── generate-jdeps-args.sh
├── pom.xml
├── run.sh
├── src
├── main
│ ├── assembly
│ │ └── zip.xml
│ ├── external-resources
│ │ ├── failchat.bat
│ │ ├── failchat.sh
│ │ ├── java-agents
│ │ │ └── transparent-webview-patch.jar
│ │ └── skins
│ │ │ ├── _shared
│ │ │ ├── common.css
│ │ │ ├── demo-messages.js
│ │ │ ├── example-messages.js
│ │ │ ├── failchat.js
│ │ │ ├── font
│ │ │ │ └── icomoon.woff
│ │ │ ├── icons
│ │ │ │ ├── click-transparency-mode.svg
│ │ │ │ ├── failchat.png
│ │ │ │ ├── goodgame.png
│ │ │ │ ├── twitch.png
│ │ │ │ ├── youtube-moderator.svg
│ │ │ │ ├── youtube-streamer.svg
│ │ │ │ ├── youtube-verified.svg
│ │ │ │ └── youtube.png
│ │ │ └── templates
│ │ │ │ ├── emoticon-raster.tmpl.html
│ │ │ │ ├── emoticon-vector.tmpl.html
│ │ │ │ ├── image.tmpl.html
│ │ │ │ ├── link.tmpl.html
│ │ │ │ ├── message.tmpl.html
│ │ │ │ ├── origin-viewers-bar.tmpl.html
│ │ │ │ └── status-message.tmpl.html
│ │ │ ├── funstream
│ │ │ ├── funstream.css
│ │ │ ├── funstream.html
│ │ │ └── icons
│ │ │ │ ├── failchat.png
│ │ │ │ ├── goodgame.png
│ │ │ │ ├── twitch.png
│ │ │ │ └── youtube.png
│ │ │ ├── glass
│ │ │ ├── glass.css
│ │ │ └── glass.html
│ │ │ └── old_sc2tv
│ │ │ ├── old_sc2tv.css
│ │ │ └── old_sc2tv.html
│ ├── kotlin
│ │ └── failchat
│ │ │ ├── AppState.kt
│ │ │ ├── AppStateManager.kt
│ │ │ ├── ConfigKeys.kt
│ │ │ ├── ConfigLoader.kt
│ │ │ ├── ConfigUtils.kt
│ │ │ ├── Dependencies.kt
│ │ │ ├── FailchatMain.kt
│ │ │ ├── FailchatServerInfo.kt
│ │ │ ├── Logging.kt
│ │ │ ├── Origin.kt
│ │ │ ├── chat
│ │ │ ├── AppConfiguration.kt
│ │ │ ├── Author.kt
│ │ │ ├── ChatClient.kt
│ │ │ ├── ChatClientCallbacks.kt
│ │ │ ├── ChatClientStatus.kt
│ │ │ ├── ChatMessage.kt
│ │ │ ├── ChatMessageHistory.kt
│ │ │ ├── ChatMessageRemover.kt
│ │ │ ├── ChatMessageSender.kt
│ │ │ ├── DeletedMessagePlaceholder.kt
│ │ │ ├── Elements.kt
│ │ │ ├── Image.kt
│ │ │ ├── ImageFormat.kt
│ │ │ ├── Link.kt
│ │ │ ├── MessageElement.kt
│ │ │ ├── MessageFilter.kt
│ │ │ ├── MessageHandler.kt
│ │ │ ├── MessageIdGenerator.kt
│ │ │ ├── OnChatMessageCallback.kt
│ │ │ ├── OnChatMessageDeletedCallback.kt
│ │ │ ├── OnStatusUpdateCallback.kt
│ │ │ ├── OriginStatus.kt
│ │ │ ├── OriginStatusManager.kt
│ │ │ ├── StatusUpdate.kt
│ │ │ ├── badge
│ │ │ │ ├── Badge.kt
│ │ │ │ ├── BadgeFinder.kt
│ │ │ │ ├── BadgeManager.kt
│ │ │ │ ├── BadgeOrigin.kt
│ │ │ │ └── BadgeStorage.kt
│ │ │ └── handlers
│ │ │ │ ├── BraceEscaper.kt
│ │ │ │ ├── ChatHistoryLogger.kt
│ │ │ │ ├── CommaHighlightHandler.kt
│ │ │ │ ├── ElementLabelEscaper.kt
│ │ │ │ ├── EmojiHandler.kt
│ │ │ │ ├── FailchatEmoticonHandler.kt
│ │ │ │ ├── IgnoreFilter.kt
│ │ │ │ ├── ImageLinkHandler.kt
│ │ │ │ ├── LinkHandler.kt
│ │ │ │ ├── OriginsStatusHandler.kt
│ │ │ │ └── SpaceSeparatedEmoticonHandler.kt
│ │ │ ├── emoticon
│ │ │ ├── ChannelEmoticonUpdater.kt
│ │ │ ├── DeletedMessagePlaceholderFactory.kt
│ │ │ ├── EmojiEmoticon.kt
│ │ │ ├── Emoticon.kt
│ │ │ ├── EmoticonAndId.kt
│ │ │ ├── EmoticonCodeIdDbCompactStorage.kt
│ │ │ ├── EmoticonCodeIdDbStorage.kt
│ │ │ ├── EmoticonCodeIdMemoryStorage.kt
│ │ │ ├── EmoticonCodeMemoryStorage.kt
│ │ │ ├── EmoticonFactory.kt
│ │ │ ├── EmoticonFinder.kt
│ │ │ ├── EmoticonIdAndCode.kt
│ │ │ ├── EmoticonIdExtractor.kt
│ │ │ ├── EmoticonLoadConfiguration.kt
│ │ │ ├── EmoticonLoadException.kt
│ │ │ ├── EmoticonLoader.kt
│ │ │ ├── EmoticonManager.kt
│ │ │ ├── EmoticonStorage.kt
│ │ │ ├── EmptyEmoticonStorage.kt
│ │ │ ├── FailchatEmoticon.kt
│ │ │ ├── FailchatEmoticonScanner.kt
│ │ │ ├── FailchatEmoticonUpdater.kt
│ │ │ ├── GlobalEmoticonUpdater.kt
│ │ │ ├── MapdbFactory.kt
│ │ │ ├── OriginEmoticonStorage.kt
│ │ │ ├── OriginEmoticonStorageFactory.kt
│ │ │ ├── ReplaceDecision.kt
│ │ │ ├── SemicolonCodeProcessor.kt
│ │ │ ├── TwitchEmoticonFactory.kt
│ │ │ └── WordReplacer.kt
│ │ │ ├── exception
│ │ │ ├── ChannelNotFoundException.kt
│ │ │ ├── ChannelOfflineException.kt
│ │ │ ├── DataNotFoundException.kt
│ │ │ ├── InvalidConfigurationException.kt
│ │ │ ├── NativeCallException.kt
│ │ │ ├── UnexpectedResponseCodeException.kt
│ │ │ ├── UnexpectedResponseException.kt
│ │ │ └── UnexpectedWsMessage.kt
│ │ │ ├── github
│ │ │ ├── GithubClient.kt
│ │ │ ├── NoReleasesFoundException.kt
│ │ │ ├── Release.kt
│ │ │ ├── ReleaseChecker.kt
│ │ │ └── Version.kt
│ │ │ ├── goodgame
│ │ │ ├── GgApi2Client.kt
│ │ │ ├── GgApiClient.kt
│ │ │ ├── GgAuthorColorHandler.kt
│ │ │ ├── GgBadgeHandler.kt
│ │ │ ├── GgChannel.kt
│ │ │ ├── GgChatClient.kt
│ │ │ ├── GgColors.kt
│ │ │ ├── GgEmoticon.kt
│ │ │ ├── GgEmoticonHandler.kt
│ │ │ ├── GgEmoticonIdExtractor.kt
│ │ │ ├── GgEmoticonLoadConfiguration.kt
│ │ │ ├── GgEmoticonLoader.kt
│ │ │ ├── GgMessage.kt
│ │ │ ├── GgViewersCountLoader.kt
│ │ │ ├── HtmlUrlCleaner.kt
│ │ │ └── StreamResponse.kt
│ │ │ ├── gui
│ │ │ ├── ChatFrame.kt
│ │ │ ├── ChatFrameLauncher.kt
│ │ │ ├── ChatGuiEventHandler.kt
│ │ │ ├── ClickTransparencyConfigurator.kt
│ │ │ ├── Extensions.kt
│ │ │ ├── FullGuiEventHandler.kt
│ │ │ ├── GuiEventHandlerIf.kt
│ │ │ ├── GuiFrames.kt
│ │ │ ├── GuiLauncher.kt
│ │ │ ├── GuiMode.kt
│ │ │ ├── Images.kt
│ │ │ ├── PortBindAlert.kt
│ │ │ ├── SettingsFrame.kt
│ │ │ ├── SkinConverter.kt
│ │ │ └── WebViewLogger.kt
│ │ │ ├── platform
│ │ │ └── windows
│ │ │ │ ├── Windows.kt
│ │ │ │ └── WindowsCtConfigurator.kt
│ │ │ ├── skin
│ │ │ ├── Skin.kt
│ │ │ ├── SkinScanner.kt
│ │ │ └── Skins.kt
│ │ │ ├── twitch
│ │ │ ├── AuthResponse.kt
│ │ │ ├── BadgesResponse.kt
│ │ │ ├── BttvApiClient.kt
│ │ │ ├── BttvChannelNotFoundException.kt
│ │ │ ├── BttvEmoticon.kt
│ │ │ ├── BttvEmoticonHandler.kt
│ │ │ ├── BttvEmoticonIdExtractor.kt
│ │ │ ├── BttvGlobalEmoticonLoadConfiguration.kt
│ │ │ ├── BttvGlobalEmoticonLoader.kt
│ │ │ ├── ConfigurationTokenContainer.kt
│ │ │ ├── EmotesResponse.kt
│ │ │ ├── FfzApiClient.kt
│ │ │ ├── FfzChannelNotFoundException.kt
│ │ │ ├── FfzEmoticon.kt
│ │ │ ├── FfzEmoticonHandler.kt
│ │ │ ├── HelixApiToken.kt
│ │ │ ├── HelixTokenContainer.kt
│ │ │ ├── InvalidTokenException.kt
│ │ │ ├── RangedEmoticon.kt
│ │ │ ├── SevenTvApiClient.kt
│ │ │ ├── SevenTvChannelNotFoundException.kt
│ │ │ ├── SevenTvChannelResponse.kt
│ │ │ ├── SevenTvEmoteSetResponse.kt
│ │ │ ├── SevenTvEmoticon.kt
│ │ │ ├── SevenTvEmoticonIdExtractor.kt
│ │ │ ├── SevenTvGlobalEmoticonLoadConfiguration.kt
│ │ │ ├── SevenTvGlobalEmoticonLoader.kt
│ │ │ ├── StreamsResponse.kt
│ │ │ ├── TokenAwareTwitchApiClient.kt
│ │ │ ├── TwitchApiClient.kt
│ │ │ ├── TwitchAuthorColorHandler.kt
│ │ │ ├── TwitchBadgeHandler.kt
│ │ │ ├── TwitchBadgeId.kt
│ │ │ ├── TwitchChatClient.kt
│ │ │ ├── TwitchEmotesTagParser.kt
│ │ │ ├── TwitchEmoticon.kt
│ │ │ ├── TwitchEmoticonHandler.kt
│ │ │ ├── TwitchEmoticonIdExtractor.kt
│ │ │ ├── TwitchEmoticonLoadConfiguration.kt
│ │ │ ├── TwitchEmoticonUrlFactory.kt
│ │ │ ├── TwitchGlobalEmoticonLoader.kt
│ │ │ ├── TwitchHighlightByPointsHandler.kt
│ │ │ ├── TwitchHighlightHandler.kt
│ │ │ ├── TwitchIrcTags.kt
│ │ │ ├── TwitchMessage.kt
│ │ │ ├── TwitchRetries.kt
│ │ │ ├── TwitchRewardHandler.kt
│ │ │ ├── TwitchViewersCountLoader.kt
│ │ │ └── UsersResponse.kt
│ │ │ ├── util
│ │ │ ├── Collections.kt
│ │ │ ├── CompletableFutures.kt
│ │ │ ├── Concurrent.kt
│ │ │ ├── Config.kt
│ │ │ ├── Coroutines.kt
│ │ │ ├── Eithers.kt
│ │ │ ├── Files.kt
│ │ │ ├── Ints.kt
│ │ │ ├── Jackson.kt
│ │ │ ├── JavaFx.kt
│ │ │ ├── Json.kt
│ │ │ ├── Lang.kt
│ │ │ ├── LateinitVal.kt
│ │ │ ├── OkHttp.kt
│ │ │ ├── OkHttpLogger.kt
│ │ │ ├── Streams.kt
│ │ │ ├── Strings.kt
│ │ │ ├── Systems.kt
│ │ │ ├── Threads.kt
│ │ │ └── Urls.kt
│ │ │ ├── viewers
│ │ │ ├── CountableOrigins.kt
│ │ │ ├── ViewersCountLoader.kt
│ │ │ ├── ViewersCountWsHandler.kt
│ │ │ └── ViewersCounter.kt
│ │ │ ├── ws
│ │ │ ├── client
│ │ │ │ └── WsClient.kt
│ │ │ └── server
│ │ │ │ ├── ClientConfigurationWsHandler.kt
│ │ │ │ ├── DeleteWsMessageHandler.kt
│ │ │ │ ├── IgnoreWsMessageHandler.kt
│ │ │ │ ├── InboundWsMessage.kt
│ │ │ │ ├── WsFrameSender.kt
│ │ │ │ ├── WsMessageDispatcher.kt
│ │ │ │ └── WsMessageHandler.kt
│ │ │ └── youtube
│ │ │ ├── LiveChatRequest.kt
│ │ │ ├── LiveChatRequestParameters.kt
│ │ │ ├── LiveChatResponse.kt
│ │ │ ├── MetadataResponse.kt
│ │ │ ├── RoleBadges.kt
│ │ │ ├── StreamStatus.kt
│ │ │ ├── UpdatedMetadataRequest.kt
│ │ │ ├── YoutubeChatClient.kt
│ │ │ ├── YoutubeClient.kt
│ │ │ ├── YoutubeClientException.kt
│ │ │ ├── YoutubeColors.kt
│ │ │ ├── YoutubeEmoticon.kt
│ │ │ ├── YoutubeHighlightHandler.kt
│ │ │ ├── YoutubeHtmlParser.kt
│ │ │ ├── YoutubeMessage.kt
│ │ │ ├── YoutubeViewCountParser.kt
│ │ │ └── YoutubeViewersCountLoader.kt
│ └── resources
│ │ ├── config
│ │ └── default.properties
│ │ ├── fx
│ │ └── settings.fxml
│ │ └── icons
│ │ ├── click-transparency-mode.png
│ │ ├── failchat.png
│ │ ├── funstream.png
│ │ ├── goodgame.png
│ │ ├── twitch.png
│ │ └── youtube.png
└── test
│ ├── kotlin
│ └── failchat
│ │ ├── Configs.kt
│ │ ├── Shared.kt
│ │ ├── Utils.kt
│ │ ├── chat
│ │ └── handlers
│ │ │ └── EmojiHandlerTest.kt
│ │ ├── emoticon
│ │ ├── FailchatEmoticonScannerTest.kt
│ │ └── WordReplacerTest.kt
│ │ ├── experiment
│ │ ├── EmojiLibTest.kt
│ │ ├── Jna.kt
│ │ ├── OkHttpWsClient.kt
│ │ └── YoutubeV2.kt
│ │ ├── github
│ │ └── GithubClientTest.kt
│ │ ├── goodgame
│ │ ├── GgApi2ClientTest.kt
│ │ └── GgApiClientTest.kt
│ │ ├── gui
│ │ ├── LoadingPopupTest.kt
│ │ └── SkinConverterTest.kt
│ │ ├── handlers
│ │ └── SemicolonCodeProcessorTest.kt
│ │ ├── twitch
│ │ ├── BttvApiClientTest.kt
│ │ ├── FfzApiClientTest.kt
│ │ ├── SevenTvApiClientTest.kt
│ │ ├── TwitchApiClientTest.kt
│ │ ├── TwitchEmoticonHandlerTest.kt
│ │ └── TwitchRetriesTest.kt
│ │ ├── util
│ │ └── StackTraceFormatTest.kt
│ │ └── youtube
│ │ ├── YoutubeClientTest.kt
│ │ ├── YoutubeHtmlParserTest.kt
│ │ └── YoutubeViewCountParserTest.kt
│ └── resources
│ ├── failchat-emoticons
│ ├── 1.jpg
│ ├── 2.jpeg
│ ├── 3.png
│ ├── 4.svg
│ ├── 5.gif
│ ├── UPPERCASE.JPG
│ ├── minus-.jpg
│ ├── so.many.dots.jpg
│ ├── underscore_.jpg
│ ├── unknown-format
│ └── unsupported-format.img
│ ├── html
│ └── live_chat.html
│ └── mockito-extensions
│ └── org.mockito.plugins.MockMaker
└── websocket-api.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | src/test/resources/** linguist-vendored
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target
2 | src/main/resources/config/private.properties
3 |
4 | #idea
5 | .idea/*
6 | !.idea/runConfigurations/
7 | failchat.iml
8 |
9 | #etc
10 | tree
11 | docs/*
12 | jdk
13 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/failchat_dev.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/_shared/templates/emoticon-vector.tmpl.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/_shared/templates/link.tmpl.html:
--------------------------------------------------------------------------------
1 | {{:domain}}
2 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/_shared/templates/message.tmpl.html:
--------------------------------------------------------------------------------
1 |
.+)\.(?jpe?g|png|gif|svg)$""", Pattern.CASE_INSENSITIVE)
25 | }
26 |
27 | fun scan(): List {
28 | val t1 = Instant.now()
29 | val emoticons = Files.list(emoticonsDirectory)
30 | .map { it.fileName.toString() }
31 | .map { fileName ->
32 | val m = fileNamePattern.matcher(fileName)
33 | if (!m.matches()) {
34 | logger.warn("Incorrect failchat emoticon file was ignored: '{}'", fileName)
35 | return@map null
36 | }
37 |
38 | Triple(fileName, m.group("code"), m.group("format"))
39 | }
40 | .filterNotNull()
41 | .map { (fileName, code, formatStr) ->
42 | val format = when (formatStr.toLowerCase()) {
43 | "svg" -> VECTOR
44 | else -> RASTER
45 | }
46 | FailchatEmoticon(code, format, locationUrlPrefix + fileName)
47 | }
48 | .collect(Collectors.toList())
49 |
50 | val t2 = Instant.now()
51 | logger.debug { "Failchat emoticons was scanned in ${Duration.between(t1, t2).toMillis()} ms" }
52 |
53 | return emoticons
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/FailchatEmoticonUpdater.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin.FAILCHAT
4 |
5 | class FailchatEmoticonUpdater(
6 | private val storage: EmoticonStorage,
7 | private val scanner: FailchatEmoticonScanner
8 | ) {
9 |
10 | fun update() {
11 | val emoticons = scanner.scan()
12 | .map { EmoticonAndId(it, it.code) }
13 |
14 | storage.clear(FAILCHAT)
15 | storage.putMapping(FAILCHAT, emoticons)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/GlobalEmoticonUpdater.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.gui.GuiEventHandler
4 | import failchat.resetEmoticonsUpdatedTime
5 | import failchat.util.executeWithCatch
6 | import mu.KotlinLogging
7 | import org.apache.commons.configuration2.Configuration
8 | import java.util.concurrent.ExecutorService
9 |
10 | class GlobalEmoticonUpdater(
11 | private val emoticonManager: EmoticonManager,
12 | private val emoticonLoadConfigurations: List>,
13 | private val backgroundExecutor: ExecutorService,
14 | private val guiEventHandler: GuiEventHandler,
15 | private val config: Configuration
16 | ) {
17 |
18 | private companion object {
19 | val logger = KotlinLogging.logger {}
20 | }
21 |
22 | fun reloadEmoticonsAsync() {
23 | config.resetEmoticonsUpdatedTime()
24 | actualizeEmoticonsAsync()
25 | }
26 |
27 | fun actualizeEmoticonsAsync() {
28 | backgroundExecutor.executeWithCatch {
29 | guiEventHandler.notifyEmoticonsAreLoading()
30 |
31 | emoticonManager.actualizeEmoticons(emoticonLoadConfigurations)
32 |
33 | guiEventHandler.notifyEmoticonsLoaded()
34 | }
35 | }
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/MapdbFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import mu.KotlinLogging
4 | import org.mapdb.DB
5 | import org.mapdb.DBMaker
6 | import java.nio.file.Files
7 | import java.nio.file.Path
8 |
9 | object MapdbFactory {
10 |
11 | private val logger = KotlinLogging.logger {}
12 |
13 | fun create(dbPath: Path): DB {
14 | Files.createDirectories(dbPath.parent)
15 |
16 | return DBMaker
17 | .fileDB(dbPath.toFile())
18 | .checksumHeaderBypass()
19 | .fileMmapEnable()
20 | .make()
21 | .also {
22 | logger.info("DB was initialized at '{}'", dbPath)
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/OriginEmoticonStorage.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin
4 | import kotlinx.coroutines.flow.Flow
5 |
6 | interface OriginEmoticonStorage {
7 |
8 | val origin: Origin
9 |
10 | fun findByCode(code: String): Emoticon?
11 |
12 | fun findById(id: String): Emoticon?
13 |
14 | fun getAll(): Collection
15 |
16 | fun count(): Int
17 |
18 | fun putAll(emoticons: Collection)
19 |
20 | /** Put all emoticons in storage from the channel. Blocking call. */
21 | fun putAll(emoticons: Flow)
22 |
23 | fun clear()
24 | }
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/OriginEmoticonStorageFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin
4 | import failchat.Origin.BTTV_CHANNEL
5 | import failchat.Origin.BTTV_GLOBAL
6 | import failchat.Origin.FAILCHAT
7 | import failchat.Origin.FRANKERFASEZ
8 | import failchat.Origin.GOODGAME
9 | import failchat.Origin.SEVEN_TV_CHANNEL
10 | import failchat.Origin.SEVEN_TV_GLOBAL
11 | import failchat.Origin.TWITCH
12 | import org.mapdb.DB
13 |
14 | object OriginEmoticonStorageFactory {
15 |
16 | //todo code db origin storage, BTTV_GLOBAL
17 |
18 | private val caseSensitiveOptions = mapOf(
19 | TWITCH to false,
20 | GOODGAME to false,
21 | FAILCHAT to false,
22 | BTTV_GLOBAL to true,
23 | BTTV_CHANNEL to true,
24 | FRANKERFASEZ to true,
25 | SEVEN_TV_GLOBAL to true,
26 | SEVEN_TV_CHANNEL to true
27 | )
28 |
29 | private val idCodeDbOrigins: List = listOf(BTTV_GLOBAL, SEVEN_TV_GLOBAL, GOODGAME)
30 | private val codeMemoryOrigins: List = listOf(BTTV_CHANNEL, SEVEN_TV_CHANNEL, FRANKERFASEZ, FAILCHAT)
31 |
32 | val dbOrigins: List = idCodeDbOrigins + TWITCH
33 |
34 | fun create(db: DB, twitchEmoticonFactory: TwitchEmoticonFactory): List {
35 | return idCodeDbOrigins.map { EmoticonCodeIdDbStorage(db, it, caseSensitiveOptions[it]!!) } +
36 | codeMemoryOrigins.map { EmoticonCodeMemoryStorage(it, caseSensitiveOptions[it]!!) } +
37 | EmoticonCodeIdDbCompactStorage(db, TWITCH, twitchEmoticonFactory)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/ReplaceDecision.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | sealed class ReplaceDecision {
4 | object Skip : ReplaceDecision()
5 | class Replace(val replacement: String) : ReplaceDecision()
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/SemicolonCodeProcessor.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import java.util.regex.Pattern
4 |
5 | object SemicolonCodeProcessor {
6 |
7 | private val emoticonCodePattern: Pattern = Pattern.compile("""(?:(?[\w-]+)):""")
8 |
9 | fun process(initialString: String, decisionMaker: (code: String) -> ReplaceDecision): String {
10 | // Can't use Matcher.appendReplacement() because it resets position when Matcher.find(start) invoked
11 | val matcher = emoticonCodePattern.matcher(initialString)
12 | val sb = lazy(LazyThreadSafetyMode.NONE) { StringBuilder() }
13 | var cursor = 0
14 |
15 | while (matcher.find(cursor)) {
16 | val code = matcher.group("code")
17 | val decision = decisionMaker.invoke(code)
18 |
19 | val end = matcher.end()
20 | when (decision) {
21 | is ReplaceDecision.Replace -> {
22 | val appendFrom = if (sb.isInitialized()) cursor else 0
23 | sb.value.append(initialString, appendFrom, matcher.start())
24 | sb.value.append(decision.replacement)
25 | cursor = end
26 | }
27 | is ReplaceDecision.Skip -> {
28 | val lastSemicolonPosition = if (end > 0) end - 1 else end
29 | if (sb.isInitialized()) {
30 | sb.value.append(initialString, cursor, lastSemicolonPosition)
31 | }
32 | cursor = lastSemicolonPosition
33 | }
34 | }
35 | }
36 |
37 | if (!sb.isInitialized()) return initialString
38 |
39 | sb.value.append(initialString, cursor, initialString.length)
40 | return sb.value.toString()
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/TwitchEmoticonFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.twitch.TwitchEmoticon
4 |
5 | class TwitchEmoticonFactory : EmoticonFactory {
6 | override fun create(id: String, code: String): Emoticon {
7 | return TwitchEmoticon(id, code)
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/WordReplacer.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import java.util.regex.Pattern
4 |
5 | object WordReplacer {
6 |
7 | val wordPattern: Pattern = Pattern.compile("""(?<=\s|^)(.+?)(?=\s|$)""")
8 |
9 | inline fun replace(initialString: String, decisionMaker: (word: String) -> ReplaceDecision): String {
10 | // Can't use Matcher.appendReplacement() because it resets position when Matcher.find(start) invoked
11 | val matcher = wordPattern.matcher(initialString)
12 | val sb = lazy(LazyThreadSafetyMode.NONE) { StringBuilder() }
13 | var cursor = 0
14 |
15 | while (matcher.find(cursor)) {
16 | val code = matcher.group(1)
17 | val decision = decisionMaker.invoke(code)
18 |
19 | val end = matcher.end()
20 | when (decision) {
21 | is ReplaceDecision.Replace -> {
22 | val appendFrom = if (sb.isInitialized()) cursor else 0
23 | sb.value.append(initialString, appendFrom, matcher.start())
24 | sb.value.append(decision.replacement)
25 | }
26 | is ReplaceDecision.Skip -> {
27 | if (sb.isInitialized()) {
28 | sb.value.append(initialString, cursor, end)
29 | }
30 | }
31 | }
32 | cursor = end
33 | }
34 |
35 | if (!sb.isInitialized()) return initialString
36 |
37 | sb.value.append(initialString, cursor, initialString.length)
38 | return sb.toString()
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/ChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | class ChannelNotFoundException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/ChannelOfflineException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | import failchat.Origin
4 |
5 | class ChannelOfflineException(
6 | val origin: Origin,
7 | val channel: String
8 | ) : Exception("origin: $origin, channel: $channel")
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/DataNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | open class DataNotFoundException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/InvalidConfigurationException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | class InvalidConfigurationException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/NativeCallException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | class NativeCallException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedResponseCodeException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | class UnexpectedResponseCodeException : UnexpectedResponseException{
4 | constructor(code: Int) : super("code: '$code'")
5 | constructor(code: Int, url: String) : super("code: '$code', url: '$url'")
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedResponseException.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | open class UnexpectedResponseException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/exception/UnexpectedWsMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.exception
2 |
3 | class UnexpectedWsMessage : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/GithubClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | import com.fasterxml.jackson.databind.JsonNode
4 | import com.fasterxml.jackson.databind.ObjectMapper
5 | import failchat.exception.UnexpectedResponseCodeException
6 | import failchat.exception.UnexpectedResponseException
7 | import failchat.util.thenUse
8 | import failchat.util.toFuture
9 | import mu.KotlinLogging
10 | import okhttp3.OkHttpClient
11 | import okhttp3.Request
12 | import java.util.concurrent.CompletableFuture
13 |
14 | class GithubClient(
15 | private val apiUrl: String,
16 | private val httpClient: OkHttpClient,
17 | private val objectMapper: ObjectMapper
18 | ) {
19 |
20 | private companion object {
21 | val logger = KotlinLogging.logger {}
22 | }
23 |
24 | fun requestLatestRelease(userName: String, repository: String): CompletableFuture {
25 | val request = Request.Builder()
26 | .url("${apiUrl.removeSuffix("/")}/repos/$userName/$repository/releases")
27 | .get()
28 | .build()
29 |
30 | return httpClient.newCall(request)
31 | .toFuture()
32 | .thenUse {
33 | if (it.code != 200) throw UnexpectedResponseCodeException(it.code)
34 | val responseBody = it.body ?: throw UnexpectedResponseException("null body")
35 | val releasesNode = objectMapper.readTree(responseBody.string())
36 | findLatestRelease(releasesNode) ?: throw NoReleasesFoundException()
37 | }
38 | }
39 |
40 | private fun findLatestRelease(releasesNode: JsonNode): Release? {
41 | return releasesNode.asSequence()
42 | .filter { !it.get("draft").asBoolean() }
43 | .filter { !it.get("prerelease").asBoolean() }
44 | .filter { !it.get("assets").isEmpty() }
45 | .map { parseRelease(it) }
46 | .filterNotNull()
47 | .firstOrNull()
48 | }
49 |
50 | private fun parseRelease(releaseNode: JsonNode): Release? {
51 | return try {
52 | Release(
53 | Version.parse(releaseNode.get("tag_name").asText()),
54 | releaseNode.get("html_url").asText(),
55 | releaseNode.get("assets").get(0).get("browser_download_url").asText()
56 | )
57 | } catch (e: Exception) {
58 | logger.warn("Failed to parse release node, skip it", e)
59 | null
60 | }
61 | }
62 |
63 | }
64 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/NoReleasesFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | class NoReleasesFoundException : Exception {
4 | constructor() : super()
5 | constructor(message: String?) : super(message)
6 | constructor(message: String?, cause: Throwable?) : super(message, cause)
7 | constructor(cause: Throwable?) : super(cause)
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/Release.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | class Release(
4 | val version: Version,
5 | val releasePageUrl: String,
6 | val assetDownloadUrl: String
7 | )
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/ReleaseChecker.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | import mu.KotlinLogging
4 | import org.apache.commons.configuration2.Configuration
5 |
6 | class ReleaseChecker(
7 | private val githubClient: GithubClient,
8 | private val config: Configuration
9 | ) {
10 |
11 | private companion object {
12 | val logger = KotlinLogging.logger {}
13 | const val lastNotifiedKey = "release-checker.latest-notified-version"
14 | }
15 |
16 | private val userName: String = config.getString("github.user-name")
17 | private val repository: String = config.getString("github.repository")
18 |
19 | fun checkNewRelease(onNewRelease: (Release) -> Unit) {
20 | if (!config.getBoolean("release-checker.enabled")) {
21 | logger.debug("Release check skipped")
22 | return
23 | }
24 |
25 | githubClient
26 | .requestLatestRelease(userName, repository)
27 | .thenApply { lastRelease ->
28 | val lastNotifiedReleaseVersion = Version.parse(config.getString(lastNotifiedKey))
29 | if (lastRelease.version <= lastNotifiedReleaseVersion) {
30 | logger.info("Latest version of application installed: '{}'", lastNotifiedReleaseVersion)
31 | return@thenApply
32 | }
33 |
34 | config.setProperty(lastNotifiedKey, lastRelease.version.toString())
35 | logger.info("Notifying about new release with version: '{}'", lastRelease.version)
36 | onNewRelease.invoke(lastRelease)
37 | }
38 | .exceptionally { logger.warn("Exception during check for new release", it) }
39 | }
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/Version.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | import java.util.regex.Pattern
4 |
5 | class Version(val major: Int, val minor: Int, val micro: Int) : Comparable {
6 |
7 | companion object {
8 | val versionPattern: Pattern = Pattern.compile("""v(\d+)\.(\d+)\.(\d+)""")
9 |
10 | fun parse(stringVersion: String): Version {
11 | val matcher = versionPattern.matcher(stringVersion)
12 | if (!matcher.matches()) throw IllegalArgumentException("Invalid version format: '$stringVersion'")
13 | return Version(matcher.group(1).toInt(), matcher.group(2).toInt(), matcher.group(3).toInt())
14 | }
15 | }
16 |
17 | override fun compareTo(other: Version): Int {
18 | var comparison: Int = major.compareTo(other.major)
19 | if (comparison != 0) return comparison
20 |
21 | comparison = minor.compareTo(other.minor)
22 | if (comparison != 0) return comparison
23 |
24 | return micro.compareTo(other.micro)
25 | }
26 |
27 | override fun toString() = "v$major.$minor.$micro"
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgApi2Client.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper
4 | import failchat.Origin.GOODGAME
5 | import failchat.exception.ChannelOfflineException
6 | import failchat.exception.UnexpectedResponseCodeException
7 | import failchat.exception.UnexpectedResponseException
8 | import failchat.util.await
9 | import okhttp3.OkHttpClient
10 | import okhttp3.Request
11 |
12 | class GgApi2Client(
13 | private val httpClient: OkHttpClient,
14 | private val objectMapper: ObjectMapper
15 | ) {
16 |
17 | private companion object {
18 | // Documentation: https://api2.goodgame.ru/apigility/documentation/Goodgame-v2
19 | const val apiUrl = "https://api2.goodgame.ru/v2"
20 | }
21 |
22 | /** @return viewers count for a goodgame video player. */
23 | suspend fun requestViewersCount(channelName: String): Int {
24 | val request = Request.Builder()
25 | .get()
26 | .url("$apiUrl/streams/$channelName")
27 | .header("Accept", "application/vnd.goodgame.v2+json")
28 | .build()
29 |
30 | val response = httpClient.newCall(request).await().use { response ->
31 | if (response.code != 200) throw UnexpectedResponseCodeException(response.code)
32 | val responseBody = response.body ?: throw UnexpectedResponseException("null body")
33 | objectMapper.readValue(responseBody.charStream(), StreamResponse::class.java)
34 | }
35 |
36 | if (response.status != "Live") {
37 | throw ChannelOfflineException(GOODGAME, channelName)
38 | }
39 |
40 | return response.playerViewers
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgAuthorColorHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.chat.MessageHandler
4 |
5 | class GgAuthorColorHandler : MessageHandler {
6 |
7 | override fun handleMessage(message: GgMessage) {
8 | val color = GgColors.byRole[message.authorColorName] ?: return
9 | message.author.color = color
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgChannel.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | data class GgChannel(
4 | val name: String,
5 | val id: Long,
6 | val premium: Boolean
7 | )
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgColors.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import javafx.scene.paint.Color
4 |
5 | object GgColors {
6 | val defaultColor: Color = Color.web("#73adff")
7 |
8 | val byRole = mapOf(
9 | "bronze" to Color.web("#e7820a"),
10 | "silver" to Color.web("#b4b4b4"),
11 | "gold" to Color.web("#eefc08"),
12 | "diamond" to Color.web("#8781bd"),
13 | "king" to Color.web("#30d5c8"),
14 | "top-one" to Color.web("#3bcbff"),
15 | "premium" to Color.web("#bd70d7"),
16 | "premium-personal" to Color.web("#31a93a"),
17 | "moderator" to Color.web("#ec4058"),
18 | "streamer" to Color.web("#e8bb00"),
19 | "streamer-helper" to Color.web("#e8bb00")
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin
4 | import failchat.chat.ImageFormat.RASTER
5 | import failchat.emoticon.Emoticon
6 |
7 | class GgEmoticon(
8 | code: String,
9 | override val url: String,
10 | val ggId: Long
11 | ) : Emoticon(Origin.GOODGAME, code, RASTER) {
12 |
13 | var animatedInstance: GgEmoticon? = null
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin.GOODGAME
4 | import failchat.chat.MessageHandler
5 | import failchat.emoticon.EmoticonFinder
6 | import failchat.emoticon.ReplaceDecision
7 | import failchat.emoticon.SemicolonCodeProcessor
8 |
9 | class GgEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 |
11 | override fun handleMessage(message: GgMessage) {
12 | message.text = SemicolonCodeProcessor.process(message.text) { code ->
13 | val emoticon = emoticonFinder.findByCode(GOODGAME, code) as? GgEmoticon
14 | ?: return@process ReplaceDecision.Skip
15 |
16 | val emoticonToAdd = if (message.authorHasPremium && emoticon.animatedInstance != null) {
17 | emoticon.animatedInstance!!
18 | } else {
19 | emoticon
20 | }
21 |
22 | val label = message.addElement(emoticonToAdd)
23 | return@process ReplaceDecision.Replace(label)
24 | }
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonIdExtractor
5 |
6 | object GgEmoticonIdExtractor : EmoticonIdExtractor {
7 |
8 | override val origin = Origin.GOODGAME
9 |
10 | override fun extractId(emoticon: GgEmoticon): String {
11 | return emoticon.ggId.toString()
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoadConfiguration
5 |
6 | class GgEmoticonLoadConfiguration(override val loader: GgEmoticonLoader) : EmoticonLoadConfiguration {
7 | override val origin = Origin.GOODGAME
8 | override val idExtractor = GgEmoticonIdExtractor
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgEmoticonLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoader
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.future.future
8 | import java.util.concurrent.CompletableFuture
9 |
10 | class GgEmoticonLoader(private val ggApiClient: GgApiClient) : EmoticonLoader {
11 |
12 | override val origin = Origin.GOODGAME
13 |
14 | override fun loadEmoticons(): CompletableFuture> {
15 | return CoroutineScope(Dispatchers.Default).future {
16 | ggApiClient.requestEmoticonList()
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin
4 | import failchat.chat.Author
5 | import failchat.chat.ChatMessage
6 |
7 | class GgMessage(
8 | id: Long,
9 | val ggId: Long,
10 | author: String,
11 | text: String,
12 | val authorHasPremium: Boolean,
13 | /** Mapping of channel id to subscription duration. */
14 | val subscriptionDuration: Map,
15 | val badgeName: String,
16 | val authorColorName: String,
17 | val sponsorLevel: Int,
18 | val authorRights: Int
19 | ) : ChatMessage(id, Origin.GOODGAME, Author(author, Origin.GOODGAME), text)
20 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/GgViewersCountLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.Origin.GOODGAME
4 | import failchat.viewers.ViewersCountLoader
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.future.future
8 | import java.util.concurrent.CompletableFuture
9 |
10 | class GgViewersCountLoader(
11 | private val ggApi2Client: GgApi2Client,
12 | private val channelName: String
13 | ) : ViewersCountLoader {
14 |
15 | override val origin = GOODGAME
16 |
17 | override fun loadViewersCount(): CompletableFuture {
18 | return CoroutineScope(Dispatchers.Default).future {
19 | ggApi2Client.requestViewersCount(channelName)
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/HtmlUrlCleaner.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.chat.MessageHandler
4 |
5 | import java.util.regex.Pattern
6 |
7 | class HtmlUrlCleaner : MessageHandler {
8 |
9 | private val htmlUrlPattern = Pattern.compile("""\1""")
10 |
11 | override fun handleMessage(message: GgMessage) {
12 | var matcher = htmlUrlPattern.matcher(message.text)
13 | while (matcher.find()) {
14 | val url = matcher.group(1)
15 | message.text = matcher.replaceFirst(url)
16 | matcher = htmlUrlPattern.matcher(message.text)
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/goodgame/StreamResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class StreamResponse(
6 | val status: String,
7 | @JsonProperty("player_viewers")
8 | val playerViewers: Int
9 | )
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ChatFrameLauncher.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import failchat.Dependencies
4 | import failchat.platform.windows.WindowsCtConfigurator
5 | import failchat.util.LateinitVal
6 | import javafx.application.Application
7 | import javafx.stage.Stage
8 |
9 | class ChatFrameLauncher : Application() {
10 |
11 | companion object {
12 | val deps = LateinitVal()
13 | }
14 |
15 | override fun start(primaryStage: Stage) {
16 | //todo remove copypaste
17 | val config = deps.get()!!.configuration
18 |
19 | val isWindows = com.sun.jna.Platform.isWindows()
20 | val ctConfigurator: ClickTransparencyConfigurator? = if (isWindows) {
21 | WindowsCtConfigurator(config)
22 | } else {
23 | null
24 | }
25 |
26 | val chat = ChatFrame(
27 | this,
28 | config,
29 | deps.get()!!.skinList,
30 | lazy { deps.get()!!.guiEventHandler },
31 | ctConfigurator
32 | )
33 |
34 | // init web engine (fixes flickering)
35 | chat.clearWebContent()
36 |
37 | chat.show()
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ChatGuiEventHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import failchat.AppStateManager
4 | import failchat.chat.ChatMessageSender
5 | import failchat.util.executeWithCatch
6 | import java.util.concurrent.Executors
7 |
8 | class ChatGuiEventHandler(
9 | private val appStateManager: AppStateManager,
10 | private val messageSender: ChatMessageSender
11 | ) : GuiEventHandler {
12 |
13 | private val executor = Executors.newSingleThreadExecutor()
14 |
15 | override fun handleStartChat() {
16 | }
17 |
18 | override fun handleStopChat() {
19 | executor.executeWithCatch {
20 | appStateManager.shutDown(true)
21 | }
22 | }
23 |
24 | override fun handleShutDown() {
25 | executor.executeWithCatch {
26 | appStateManager.shutDown(true)
27 | }
28 | }
29 |
30 | override fun handleResetUserConfiguration() {
31 | }
32 |
33 | override fun handleConfigurationChange() {
34 | executor.executeWithCatch {
35 | messageSender.sendClientConfiguration()
36 | }
37 | }
38 |
39 | override fun handleClearChat() {
40 | executor.executeWithCatch {
41 | messageSender.sendClearChat()
42 | }
43 | }
44 |
45 | override fun notifyEmoticonsAreLoading() {
46 | }
47 |
48 | override fun notifyEmoticonsLoaded() {
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/ClickTransparencyConfigurator.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import javafx.stage.Stage
4 |
5 | interface ClickTransparencyConfigurator {
6 |
7 | fun configureClickTransparency(stage: Stage)
8 |
9 | fun removeClickTransparency(stage: Stage)
10 | }
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/Extensions.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import javafx.scene.control.TextField
4 |
5 | fun TextField.configureChannelField(editable: Boolean) {
6 | if (editable) {
7 | style = ""
8 | isEditable = true
9 | } else {
10 | style = "-fx-background-color: lightgrey"
11 | isEditable = false
12 | }
13 | }
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiEventHandlerIf.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | interface GuiEventHandler {
4 |
5 | fun handleStartChat()
6 |
7 | fun handleStopChat()
8 |
9 | fun handleShutDown()
10 |
11 | fun handleResetUserConfiguration()
12 |
13 | fun handleConfigurationChange()
14 |
15 | fun handleClearChat()
16 |
17 | fun notifyEmoticonsAreLoading()
18 |
19 | fun notifyEmoticonsLoaded()
20 |
21 | }
22 |
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiFrames.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | class GuiFrames(
4 | val settingsFrame: SettingsFrame,
5 | val chatFrame: ChatFrame
6 | )
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/GuiMode.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | enum class GuiMode {
4 | NO_GUI,
5 | CHAT_ONLY,
6 | FULL_GUI
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/Images.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import javafx.scene.image.Image
4 |
5 | object Images {
6 | val appIcon = Image(Images::class.java.getResourceAsStream("/icons/failchat.png"))
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/PortBindAlert.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import failchat.FailchatServerInfo
4 | import javafx.application.Application
5 | import javafx.scene.control.Alert
6 | import javafx.scene.control.Alert.AlertType
7 | import javafx.stage.Stage
8 |
9 | class PortBindAlert : Application() {
10 |
11 | override fun start(primaryStage: Stage) {
12 | val alert = Alert(AlertType.ERROR)
13 |
14 | alert.title = "Launch error"
15 | alert.headerText = "Looks like failchat is already running."
16 | alert.contentText = "Failed to create socket at ${FailchatServerInfo.host.hostAddress}:${FailchatServerInfo.port}"
17 |
18 | val stage = alert.dialogPane.scene.window as Stage
19 | stage.icons.setAll(Images.appIcon)
20 |
21 | alert.showAndWait()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/SkinConverter.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import failchat.skin.Skin
4 | import javafx.util.StringConverter
5 | import mu.KotlinLogging
6 |
7 | class SkinConverter(skins: List) : StringConverter() {
8 |
9 | private companion object {
10 | val logger = KotlinLogging.logger {}
11 | }
12 |
13 | private val skinMap: Map = skins.map { it.name to it }.toMap()
14 | private val defaultSkin: Skin = skins.first()
15 |
16 | override fun toString(skin: Skin): String {
17 | return skin.name
18 | }
19 |
20 | override fun fromString(skinName: String): Skin {
21 | return skinMap.get(skinName)
22 | ?: run {
23 | logger.warn { "Unknown skin '$skinName', default skin '${defaultSkin.name}' will be used" }
24 | defaultSkin
25 | }
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/gui/WebViewLogger.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import org.slf4j.Logger
4 | import org.slf4j.LoggerFactory
5 |
6 | object WebViewLogger {
7 |
8 | private val logger: Logger = LoggerFactory.getLogger(WebViewLogger::class.java)
9 |
10 | fun log(text: String) {
11 | logger.debug(text)
12 | }
13 |
14 | fun error(text: String) {
15 | logger.warn(text)
16 | }
17 |
18 | }
19 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/platform/windows/WindowsCtConfigurator.kt:
--------------------------------------------------------------------------------
1 | package failchat.platform.windows
2 |
3 | import com.sun.jna.platform.win32.WinDef.HWND
4 | import failchat.ConfigKeys
5 | import failchat.gui.ClickTransparencyConfigurator
6 | import javafx.stage.Stage
7 | import javafx.stage.StageStyle.DECORATED
8 | import javafx.stage.StageStyle.TRANSPARENT
9 | import mu.KotlinLogging
10 | import org.apache.commons.configuration2.Configuration
11 |
12 | class WindowsCtConfigurator(private val config: Configuration) : ClickTransparencyConfigurator {
13 |
14 | private companion object {
15 | val logger = KotlinLogging.logger {}
16 | }
17 |
18 | override fun configureClickTransparency(stage: Stage) {
19 | if (!config.getBoolean(ConfigKeys.clickTransparency)) return
20 |
21 | val handle = getWindowHandle(stage) ?: return
22 |
23 | try {
24 | Windows.makeWindowClickTransparent(handle)
25 | } catch (t: Throwable) {
26 | logger.error("Failed to make clicks transparent for {} frame", stage.style, t)
27 | }
28 | }
29 |
30 | override fun removeClickTransparency(stage: Stage) {
31 | val handle = getWindowHandle(stage) ?: return
32 |
33 | try {
34 | val removeLayeredStyle = when (stage.style) {
35 | DECORATED -> true
36 | TRANSPARENT -> false
37 | else -> throw IllegalArgumentException("StageStyle: ${stage.style}")
38 | }
39 |
40 | Windows.makeWindowClickOpaque(handle, removeLayeredStyle)
41 | } catch (t: Throwable) {
42 | logger.error("Failed to make clicks opaque for {} frame", stage.style, t)
43 | }
44 | }
45 |
46 | private fun getWindowHandle(stage: Stage): HWND? {
47 | return try {
48 | Windows.getWindowHandle(stage)
49 | } catch (t: Throwable) {
50 | logger.error("Failed to get handle for {} window", stage.style, t)
51 | null
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/Skin.kt:
--------------------------------------------------------------------------------
1 | package failchat.skin
2 |
3 | import java.nio.file.Path
4 |
5 | class Skin(
6 | val name: String,
7 | val htmlPath: Path
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/SkinScanner.kt:
--------------------------------------------------------------------------------
1 | package failchat.skin
2 |
3 | import java.nio.file.Files
4 | import java.nio.file.Path
5 |
6 | class SkinScanner(workDirectory: Path) {
7 |
8 | private val skinsDirectory: Path = workDirectory.resolve("skins")
9 |
10 | fun scan(): List {
11 | Files.newDirectoryStream(skinsDirectory).use { stream ->
12 | return stream
13 | .filterNotNull()
14 | .filterNot { it.fileName.toString().startsWith("_") } //ignore _shared directory
15 | .map {
16 | val skinName = it.fileName.toString()
17 | Skin(skinName, resolveSkinPath(skinName))
18 | }
19 | .filter { Files.exists(it.htmlPath) }
20 | }
21 | }
22 |
23 | private fun resolveSkinPath(skinName: String): Path {
24 | return skinsDirectory.resolve(skinName).resolve("$skinName.html")
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/skin/Skins.kt:
--------------------------------------------------------------------------------
1 | package failchat.skin
2 |
3 | object Skins {
4 | const val default = "old_sc2tv"
5 | }
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/AuthResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class AuthResponse(
6 | @JsonProperty("access_token")
7 | val accessToken: String,
8 | @JsonProperty("expires_in")
9 | val expiresIn: Long
10 | )
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BadgesResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class BadgesResponse(
6 | val data: List
7 | ) {
8 |
9 | data class Data(
10 | @JsonProperty("set_id")
11 | val setId: String,
12 | val versions: List
13 | )
14 |
15 | data class Version(
16 | val id: String,
17 | @JsonProperty("image_url_1x")
18 | val imageUrl1x: String,
19 | val description: String
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class BttvChannelNotFoundException(val channel: String) : Exception()
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.ImageFormat.RASTER
5 | import failchat.emoticon.Emoticon
6 |
7 | class BttvEmoticon(
8 | origin: Origin,
9 | code: String,
10 | override val url: String
11 | ) : Emoticon(origin, code, RASTER)
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticonHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.MessageHandler
5 | import failchat.emoticon.EmoticonFinder
6 | import failchat.emoticon.ReplaceDecision
7 | import failchat.emoticon.WordReplacer
8 |
9 | class BttvEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 |
11 | override fun handleMessage(message: TwitchMessage) {
12 | handleEmoticons(message, Origin.BTTV_GLOBAL)
13 | handleEmoticons(message, Origin.BTTV_CHANNEL)
14 | }
15 |
16 | private fun handleEmoticons(message: TwitchMessage, origin: Origin) {
17 | message.text = WordReplacer.replace(message.text) { code ->
18 | val emoticon = emoticonFinder.findByCode(origin, code)
19 | ?: return@replace ReplaceDecision.Skip
20 | val label = message.addElement(emoticon)
21 | return@replace ReplaceDecision.Replace(label)
22 | }
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonIdExtractor
5 |
6 | object BttvEmoticonIdExtractor : EmoticonIdExtractor {
7 |
8 | override val origin = Origin.BTTV_GLOBAL
9 |
10 | override fun extractId(emoticon: BttvEmoticon): String {
11 | return emoticon.code
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvGlobalEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoadConfiguration
5 |
6 | class BttvGlobalEmoticonLoadConfiguration(
7 | override val loader: BttvGlobalEmoticonLoader
8 | ) : EmoticonLoadConfiguration {
9 | override val origin = Origin.BTTV_GLOBAL
10 | override val idExtractor = BttvEmoticonIdExtractor
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/BttvGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoader
5 | import java.util.concurrent.CompletableFuture
6 |
7 | class BttvGlobalEmoticonLoader(private val bttvApiClient: BttvApiClient) : EmoticonLoader {
8 |
9 | override val origin = Origin.BTTV_GLOBAL
10 |
11 | override fun loadEmoticons(): CompletableFuture> {
12 | return bttvApiClient.loadGlobalEmoticons()
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/ConfigurationTokenContainer.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.ConfigKeys
4 | import mu.KotlinLogging
5 | import org.apache.commons.configuration2.Configuration
6 | import java.time.Instant
7 |
8 | class ConfigurationTokenContainer(
9 | private val config: Configuration
10 | ) : HelixTokenContainer {
11 |
12 | private companion object {
13 | val logger = KotlinLogging.logger {}
14 | }
15 |
16 | override fun getToken(): HelixApiToken? {
17 | val expiresAt = Instant.ofEpochMilli(config.getLong(ConfigKeys.Twitch.expiresAt, 0))
18 | val now = Instant.now()
19 | if (now > expiresAt) {
20 | return null
21 | }
22 |
23 | return HelixApiToken(config.getString("twitch.bearer-token"), expiresAt)
24 | }
25 |
26 | override fun setToken(token: HelixApiToken) {
27 | config.setProperty(ConfigKeys.Twitch.token, token.value)
28 | config.setProperty(ConfigKeys.Twitch.expiresAt, token.expiresAt.toEpochMilli())
29 | logger.info("Helix token was saved to configuration")
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/EmotesResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | data class EmotesResponse(
4 | val data: List
5 | ) {
6 |
7 | data class Data(
8 | val id: String,
9 | val name: String
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzApiClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper
4 | import failchat.util.await
5 | import failchat.util.getBodyIfStatusIs
6 | import failchat.util.nonNullBody
7 | import failchat.util.withSuffix
8 | import okhttp3.OkHttpClient
9 | import okhttp3.Request
10 |
11 | class FfzApiClient(
12 | private val httpClient: OkHttpClient,
13 | private val objectMapper: ObjectMapper,
14 | apiUrl: String
15 | ) {
16 |
17 | private val apiUrl = apiUrl.withSuffix("/")
18 |
19 | /** @throws [FfzChannelNotFoundException]. */
20 | suspend fun requestEmoticons(roomName: String): List {
21 | val request = Request.Builder()
22 | .url(apiUrl + "room/" + roomName)
23 | .get()
24 | .build()
25 |
26 | val parsedBody = httpClient.newCall(request).await().use {
27 | if (it.code == 404) throw FfzChannelNotFoundException(roomName)
28 | val bodyText = it.getBodyIfStatusIs(200).nonNullBody.string()
29 | objectMapper.readTree(bodyText)
30 | }
31 |
32 | val emoticonSet = parsedBody.get("room").get("set").longValue()
33 |
34 | return parsedBody
35 | .get("sets")
36 | .get(emoticonSet.toString())
37 | .get("emoticons")
38 | .map { emoticonNode ->
39 | FfzEmoticon(
40 | emoticonNode.get("name").textValue(),
41 | emoticonNode.get("urls").get("1").textValue()
42 | )
43 | }
44 | }
45 |
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class FfzChannelNotFoundException(val channel: String) : Exception()
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.ImageFormat
5 | import failchat.emoticon.Emoticon
6 |
7 | class FfzEmoticon(
8 | code: String,
9 | override val url: String
10 | ) : Emoticon(Origin.FRANKERFASEZ, code, ImageFormat.RASTER)
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/FfzEmoticonHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.MessageHandler
5 | import failchat.emoticon.EmoticonFinder
6 | import failchat.emoticon.ReplaceDecision
7 | import failchat.emoticon.WordReplacer
8 |
9 | class FfzEmoticonHandler(private val emoticonFinder: EmoticonFinder) : MessageHandler {
10 |
11 | override fun handleMessage(message: TwitchMessage) {
12 | message.text = WordReplacer.replace(message.text) { code ->
13 | val emoticon = emoticonFinder.findByCode(Origin.FRANKERFASEZ, code)
14 | ?: return@replace ReplaceDecision.Skip
15 | val label = message.addElement(emoticon)
16 | return@replace ReplaceDecision.Replace(label)
17 | }
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/HelixApiToken.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import java.time.Instant
4 |
5 | data class HelixApiToken(
6 | val value: String,
7 | val expiresAt: Instant
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/HelixTokenContainer.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | interface HelixTokenContainer {
4 | fun getToken(): HelixApiToken?
5 | fun setToken(token: HelixApiToken)
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/InvalidTokenException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class InvalidTokenException : RuntimeException("Invalid twitch token")
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/RangedEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class RangedEmoticon(val emoticon: TwitchEmoticon, val position: IntRange)
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvChannelNotFoundException.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class SevenTvChannelNotFoundException(val channelId: Long) : Exception()
4 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvChannelResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class SevenTvChannelResponse(
6 | @JsonProperty("emote_set")
7 | val emoteSet: SevenTvEmoteSetResponse
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoteSetResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | data class SevenTvEmoteSetResponse(
4 | val emotes: List = listOf()
5 | ) {
6 |
7 | data class Emote(
8 | val id: String,
9 | val name: String,
10 | val data: Data
11 | )
12 |
13 | data class Data(
14 | val host: Host
15 | )
16 |
17 | data class Host(
18 | val url: String,
19 | val files: List
20 | )
21 |
22 | data class File(
23 | val name: String,
24 | val format: String,
25 | val width: Int
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.ImageFormat
5 | import failchat.emoticon.Emoticon
6 |
7 | class SevenTvEmoticon(
8 | origin: Origin,
9 | code: String,
10 | val id: String,
11 | override val url: String
12 | ) : Emoticon(origin, code, ImageFormat.RASTER)
13 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonIdExtractor
5 |
6 | object SevenTvEmoticonIdExtractor : EmoticonIdExtractor {
7 |
8 | override val origin = Origin.SEVEN_TV_GLOBAL
9 |
10 | override fun extractId(emoticon: SevenTvEmoticon): String {
11 | return emoticon.id
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvGlobalEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoadConfiguration
5 |
6 | class SevenTvGlobalEmoticonLoadConfiguration(
7 | override val loader: SevenTvGlobalEmoticonLoader
8 | ) : EmoticonLoadConfiguration {
9 | override val origin = Origin.SEVEN_TV_GLOBAL
10 | override val idExtractor = SevenTvEmoticonIdExtractor
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/SevenTvGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoader
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.future.future
8 | import java.util.concurrent.CompletableFuture
9 |
10 | class SevenTvGlobalEmoticonLoader(private val sevenTvApiClient: SevenTvApiClient) : EmoticonLoader {
11 |
12 | override val origin = Origin.SEVEN_TV_GLOBAL
13 |
14 | override fun loadEmoticons(): CompletableFuture> {
15 | return CoroutineScope(Dispatchers.Default).future {
16 | sevenTvApiClient.loadGlobalEmoticons()
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/StreamsResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import com.fasterxml.jackson.annotation.JsonProperty
4 |
5 | data class StreamsResponse(
6 | val data: List
7 | ) {
8 | data class Data(
9 | @JsonProperty("viewer_count")
10 | val viewerCount: Int,
11 | @JsonProperty("user_login")
12 | val userLogin: String
13 | )
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TokenAwareTwitchApiClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.badge.ImageBadge
4 | import kotlinx.coroutines.sync.Mutex
5 | import kotlinx.coroutines.sync.withLock
6 |
7 | /**
8 | * The [TwitchApiClient] wrapper that:
9 | * - reuses existing token.
10 | * - retries the request if the token is expired.
11 | * */
12 | class TokenAwareTwitchApiClient(
13 | private val twitchApiClient: TwitchApiClient,
14 | private val clientSecret: String,
15 | private val tokenContainer: HelixTokenContainer
16 | ) {
17 |
18 | private val mutex = Mutex() // forbid parallel execution to prevent multiple tokens generation in the same time
19 |
20 | suspend fun getUserId(userName: String): Long {
21 | return mutex.withLock {
22 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
23 | twitchApiClient.getUserId(userName, it)
24 | }
25 | }
26 | }
27 |
28 | suspend fun getViewersCount(userName: String): Int {
29 | return mutex.withLock {
30 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
31 | twitchApiClient.getViewersCount(userName, it)
32 | }
33 | }
34 | }
35 |
36 | suspend fun getGlobalEmoticons(): List {
37 | return mutex.withLock {
38 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
39 | twitchApiClient.getGlobalEmoticons(it)
40 | }
41 | }
42 | }
43 |
44 | suspend fun getFirstLiveChannelName(): String {
45 | return mutex.withLock {
46 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
47 | twitchApiClient.getFirstLiveChannelName(it)
48 | }
49 | }
50 | }
51 |
52 | suspend fun getGlobalBadges(): Map {
53 | return mutex.withLock {
54 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
55 | twitchApiClient.getGlobalBadges(it)
56 | }
57 | }
58 | }
59 |
60 | suspend fun getChannelBadges(channelId: Long): Map {
61 | return mutex.withLock {
62 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
63 | twitchApiClient.getChannelBadges(channelId, it)
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchAuthorColorHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 | import failchat.util.notEmptyOrNull
5 | import javafx.scene.paint.Color
6 |
7 | class TwitchAuthorColorHandler : MessageHandler {
8 |
9 | override fun handleMessage(message: TwitchMessage) {
10 | val colorString = message.tags.get(TwitchIrcTags.color).notEmptyOrNull() ?: return
11 | message.author.color = Color.web(colorString)
12 | }
13 |
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchBadgeHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 | import failchat.chat.badge.Badge
5 | import failchat.chat.badge.BadgeFinder
6 | import failchat.chat.badge.BadgeOrigin.TWITCH_CHANNEL
7 | import failchat.chat.badge.BadgeOrigin.TWITCH_GLOBAL
8 | import failchat.util.notEmptyOrNull
9 | import mu.KotlinLogging
10 |
11 | class TwitchBadgeHandler(
12 | private val badgeFinder: BadgeFinder
13 | ) : MessageHandler {
14 |
15 | private companion object {
16 | val logger = KotlinLogging.logger {}
17 | }
18 |
19 | override fun handleMessage(message: TwitchMessage) {
20 | val badgesTag = message.tags.get(TwitchIrcTags.badges).notEmptyOrNull() ?: return
21 |
22 | val messageBadgeIds = parseBadgesTag(badgesTag)
23 |
24 | messageBadgeIds.forEach { messageBadgeId ->
25 | val badge: Badge? = badgeFinder.findBadge(TWITCH_CHANNEL, messageBadgeId)
26 | ?: badgeFinder.findBadge(TWITCH_GLOBAL, messageBadgeId)
27 |
28 | if (badge == null) {
29 | logger.debug("Badge not found. Origin: {}, {}; badge id: {}", TWITCH_CHANNEL, TWITCH_GLOBAL, messageBadgeId)
30 | return@forEach
31 | }
32 |
33 | message.addBadge(badge)
34 | }
35 | }
36 |
37 | private fun parseBadgesTag(badgesTag: String): List {
38 | return badgesTag
39 | .split(',')
40 | .asSequence()
41 | .map { it.split('/', limit = 2) }
42 | .map {
43 | val version = if (it.size < 2) "" else it[1]
44 | TwitchBadgeId(it[0], version)
45 | }
46 | .toList()
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchBadgeId.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | data class TwitchBadgeId(
4 | val setId: String,
5 | val version: String
6 | )
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmotesTagParser.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | class TwitchEmotesTagParser {
4 |
5 | fun parse(emotesTag: String, messageText: String): List {
6 | /*
7 | * Message example:
8 | * emotes tag = "25:0-4,10-14/1902:16-20"
9 | * text = "Kappa 123 Kappa Keepo"
10 | *
11 | * If there is no emotes, emotesTag == null
12 | * */
13 | return emotesTag
14 | .split("/")
15 | .flatMap { emoteWithPositions ->
16 | val (emoticonId, positionsString) = emoteWithPositions.split(":", limit = 2)
17 |
18 | val positions = positionsString
19 | .split(",")
20 | .map {
21 | val (start, end) = it.split("-", limit = 2)
22 | IntRange(start.toInt(), end.toInt())
23 | }
24 |
25 | positions.map {
26 | val emoticon = TwitchEmoticon(
27 | twitchId = emoticonId,
28 | code = messageText.substring(it)
29 | )
30 |
31 | RangedEmoticon(emoticon, it)
32 | }
33 |
34 | }
35 | .sortedBy { it.position.start }
36 | }
37 | }
38 |
39 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.ImageFormat.RASTER
5 | import failchat.emoticon.Emoticon
6 |
7 | class TwitchEmoticon(
8 | val twitchId: String,
9 | code: String
10 | ) : Emoticon(Origin.TWITCH, code, RASTER) {
11 |
12 | override val url: String
13 | get() = TwitchEmoticonUrlFactory.create(twitchId)
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 | import failchat.util.notEmptyOrNull
5 | import mu.KotlinLogging
6 |
7 | class TwitchEmoticonHandler(
8 | private val twitchEmotesTagParser: TwitchEmotesTagParser
9 | ) : MessageHandler {
10 |
11 | private companion object {
12 | val logger = KotlinLogging.logger {}
13 | }
14 |
15 | override fun handleMessage(message: TwitchMessage) {
16 | val emotesTag = message.tags.get(TwitchIrcTags.emotes).notEmptyOrNull() ?: return
17 |
18 | try {
19 | replaceEmoteCodes(emotesTag, message)
20 | } catch (e: Exception) {
21 | logger.warn(e) { "Failed to replace emoticon codes with element labels. emotes tag: '$emotesTag', " +
22 | "message text: '${message.text}'" }
23 | }
24 | }
25 |
26 | private fun replaceEmoteCodes(emotesTag: String, message: TwitchMessage) {
27 | val rangedEmoticons = twitchEmotesTagParser.parse(emotesTag, message.text)
28 |
29 | var offset = 0
30 | val sb = StringBuilder(message.text)
31 | rangedEmoticons.forEach { item ->
32 | val elementLabel = message.addElement(item.emoticon)
33 | val labelLength = elementLabel.length
34 | val position = item.position
35 |
36 | sb.replace(position.start + offset, position.endInclusive + 1 + offset, elementLabel)
37 | // positive - to the right, negative - to the left
38 | offset += labelLength - (position.endInclusive - position.start + 1)
39 | }
40 |
41 | message.text = sb.toString()
42 |
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonIdExtractor.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonIdExtractor
5 |
6 | object TwitchEmoticonIdExtractor: EmoticonIdExtractor {
7 | override val origin = Origin.TWITCH
8 |
9 | override fun extractId(emoticon: TwitchEmoticon): String {
10 | return emoticon.twitchId.toString()
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonLoadConfiguration.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoadConfiguration
5 |
6 | class TwitchEmoticonLoadConfiguration(
7 | override val loader: TwitchGlobalEmoticonLoader
8 | ) : EmoticonLoadConfiguration {
9 | override val origin = Origin.TWITCH
10 | override val idExtractor = TwitchEmoticonIdExtractor
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchEmoticonUrlFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | object TwitchEmoticonUrlFactory {
4 | fun create(id: String): String {
5 | return "https://static-cdn.jtvnw.net/emoticons/v2/$id/default/light/1.0"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchGlobalEmoticonLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.emoticon.EmoticonLoader
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.future.future
8 | import java.util.concurrent.CompletableFuture
9 |
10 | /** Uses official twitch API. */
11 | class TwitchGlobalEmoticonLoader(
12 | private val twitchClient: TokenAwareTwitchApiClient
13 | ) : EmoticonLoader {
14 |
15 | override val origin = Origin.TWITCH
16 |
17 | override fun loadEmoticons(): CompletableFuture> {
18 | return CoroutineScope(Dispatchers.Default).future { twitchClient.getGlobalEmoticons() }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchHighlightByPointsHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 |
5 | class TwitchHighlightByPointsHandler : MessageHandler {
6 |
7 | override fun handleMessage(message: TwitchMessage) {
8 | if (message.tags.get(TwitchIrcTags.msgId) == "highlighted-message") {
9 | message.highlightedBackground = true
10 | }
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchHighlightHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 |
5 | class TwitchHighlightHandler(channel: String) : MessageHandler {
6 |
7 | private val appeal = "@" + channel
8 |
9 | override fun handleMessage(message: TwitchMessage) {
10 | if (message.text.contains(appeal, ignoreCase = true)) {
11 | message.highlighted = true
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchIrcTags.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | object TwitchIrcTags {
4 | const val emotes = "emotes"
5 | const val badges = "badges"
6 | const val customRewardId = "custom-reward-id"
7 | const val msgId = "msg-id"
8 | const val color = "color"
9 | const val displayName = "display-name"
10 | }
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.chat.Author
5 | import failchat.chat.ChatMessage
6 |
7 | class TwitchMessage(
8 | id: Long,
9 | author: String,
10 | text: String,
11 | val tags: Map
12 | ) : ChatMessage(id, Origin.TWITCH, Author(author, Origin.TWITCH), text)
13 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchRetries.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import mu.KotlinLogging
4 |
5 | val logger = KotlinLogging.logger {}
6 |
7 | suspend fun doWithRetryOnAuthError(
8 | twitchApiClient: TwitchApiClient,
9 | clientSecret: String,
10 | tokenContainer: HelixTokenContainer,
11 | operation: suspend (String) -> T
12 | ): T {
13 | val existingToken = tokenContainer.getToken()
14 |
15 | if (existingToken != null) {
16 | try {
17 | return operation(existingToken.value)
18 | } catch (e: InvalidTokenException) {
19 | logger.info("Invalid twitch token")
20 | }
21 | }
22 |
23 | val newToken = twitchApiClient.generateToken(clientSecret)
24 | tokenContainer.setToken(newToken)
25 | return operation(newToken.value)
26 | }
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchRewardHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.MessageHandler
4 |
5 | class TwitchRewardHandler : MessageHandler {
6 |
7 | override fun handleMessage(message: TwitchMessage) {
8 | if (message.tags.get(TwitchIrcTags.customRewardId) != null) {
9 | message.highlightedBackground = true
10 | }
11 | }
12 |
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TwitchViewersCountLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.Origin
4 | import failchat.viewers.ViewersCountLoader
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers
7 | import kotlinx.coroutines.future.future
8 | import java.util.concurrent.CompletableFuture
9 |
10 | class TwitchViewersCountLoader(
11 | private val userName: String,
12 | private val twitchClient: TokenAwareTwitchApiClient
13 | ) : ViewersCountLoader {
14 |
15 | override val origin = Origin.TWITCH
16 |
17 | override fun loadViewersCount(): CompletableFuture {
18 | return CoroutineScope(Dispatchers.Default).future { twitchClient.getViewersCount(userName) }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/UsersResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | data class UsersResponse(
4 | val data: List
5 | ) {
6 | data class Data(
7 | val id: Long
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Collections.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import java.util.EnumMap
4 | import java.util.EnumSet
5 |
6 | inline fun , V> enumMap(): EnumMap = java.util.EnumMap(K::class.java)
7 | inline fun > enumSet(): EnumSet = java.util.EnumSet.noneOf(T::class.java)
8 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/CompletableFutures.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import mu.KotlinLogging
4 | import java.time.Duration
5 | import java.util.concurrent.CompletableFuture
6 | import java.util.concurrent.CompletionException
7 | import java.util.concurrent.CompletionStage
8 | import java.util.concurrent.ExecutionException
9 | import java.util.concurrent.TimeUnit
10 |
11 | private val logger = KotlinLogging.logger {}
12 |
13 | fun Collection>.compose(): CompletableFuture {
14 | return CompletableFuture.allOf(*this.toTypedArray()) // excessive array copying here because of spread operator
15 | }
16 |
17 | fun CompletableFuture.get(timeout: Duration): T = this.get(timeout.toMillis(), TimeUnit.MILLISECONDS)
18 |
19 | fun completedFuture(value: T): CompletableFuture = CompletableFuture.completedFuture(value)
20 | fun completedFuture(): CompletableFuture = CompletableFuture.completedFuture(Unit)
21 |
22 | fun exceptionalFuture(exception: Throwable): CompletableFuture {
23 | return CompletableFuture().apply { completeExceptionally(exception) }
24 | }
25 |
26 | /**
27 | * Unwrap [CompletionException] and return it's cause.
28 | * @throws NullCompletionCauseException if cause of [CompletionException] is null.
29 | * */
30 | fun Throwable.completionCause(): Throwable {
31 | return if (this is CompletionException) {
32 | this.cause ?: throw NullCompletionCauseException(this)
33 | } else {
34 | this
35 | }
36 | }
37 |
38 | private class NullCompletionCauseException(e: CompletionException) : Exception(e)
39 |
40 | fun CompletionStage.logException() {
41 | whenComplete { _, t ->
42 | if (t !== null) logger.error("Unhandled exception from CompletionStage", t)
43 | }
44 | }
45 |
46 | /**
47 | * Execute operation and close the [T].
48 | * */
49 | inline fun CompletableFuture.thenUse(crossinline operation: (T) -> R): CompletableFuture {
50 | return this.thenApply { response ->
51 | response.use(operation)
52 | }
53 | }
54 |
55 | /**
56 | * Perform [action], if it throws [ExecutionException] cause will be thrown instead.
57 | * */
58 | inline fun doUnwrappingExecutionException(action: () -> T): T {
59 | try {
60 | return action.invoke()
61 | } catch (e: ExecutionException) {
62 | throw e.cause ?: e
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Concurrent.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import mu.KotlinLogging
4 | import java.time.Duration
5 | import java.util.concurrent.ExecutorService
6 | import java.util.concurrent.ScheduledExecutorService
7 | import java.util.concurrent.ScheduledFuture
8 | import java.util.concurrent.TimeUnit
9 | import java.util.concurrent.atomic.AtomicBoolean
10 | import java.util.concurrent.atomic.AtomicReference
11 | import java.util.concurrent.locks.Condition
12 |
13 | private val logger = KotlinLogging.logger {}
14 |
15 | fun sleep(duration: Duration) = Thread.sleep(duration.toMillis())
16 |
17 | fun Condition.await(duration: Duration) = this.await(duration.toMillis(), TimeUnit.MILLISECONDS)
18 |
19 | fun ScheduledExecutorService.schedule(delay: Duration, command: () -> Unit): ScheduledFuture<*> {
20 | return this.schedule(command, delay.toMillis(), TimeUnit.MILLISECONDS)
21 | }
22 |
23 | fun ScheduledExecutorService.scheduleWithCatch(delay: Duration, command: () -> Unit): ScheduledFuture<*> {
24 | return schedule(delay) {
25 | try {
26 | command.invoke()
27 | } catch (t: Throwable) {
28 | logger.error("Uncaught exception during executing scheduled task $command", t)
29 | }
30 | }
31 | }
32 |
33 | inline var AtomicReference.value
34 | get(): T = this.get()
35 | set(value: T) = this.set(value)
36 |
37 | fun ExecutorService.executeWithCatch(task: () -> Unit) {
38 | return execute {
39 | try {
40 | task.invoke()
41 | } catch (t: Throwable) {
42 | logger.error("Uncaught exception", t)
43 | }
44 | }
45 | }
46 |
47 | inline var AtomicBoolean.value
48 | get(): Boolean = this.get()
49 | set(value: Boolean) = this.set(value)
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Config.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import org.apache.commons.configuration2.Configuration
4 |
5 | /**
6 | * Invert boolean value by specified [key] and return the new value.
7 | * */
8 | fun Configuration.invertBoolean(key: String): Boolean {
9 | val newValue = !getBoolean(key)
10 | setProperty(key, newValue)
11 | return newValue
12 | }
13 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Coroutines.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import kotlinx.coroutines.CoroutineExceptionHandler
4 | import kotlinx.coroutines.CoroutineName
5 | import kotlinx.coroutines.channels.SendChannel
6 | import mu.KotlinLogging
7 | import kotlin.coroutines.CoroutineContext
8 |
9 | object CoroutineExceptionLogger : CoroutineExceptionHandler {
10 |
11 | private val logger = KotlinLogging.logger {}
12 |
13 | override val key = CoroutineExceptionHandler.Key
14 |
15 | override fun handleException(context: CoroutineContext, exception: Throwable) {
16 | logger.error(exception) { "Uncaught exception in coroutine '${context[CoroutineName.Key]?.name}'" }
17 | }
18 | }
19 |
20 | fun SendChannel.offerOrThrow(element: T) {
21 | val offered = offer(element)
22 | if (!offered) throw RuntimeException("SendChannel.offer operation failed")
23 | }
24 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Eithers.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import either.Either
4 | import either.fold
5 |
6 | fun Either.any() = fold({ it }, { it })
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Files.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import ch.qos.logback.core.util.FileSize
4 |
5 | fun Long.bytesToMegabytes() = this / FileSize.MB_COEFFICIENT
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Ints.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | fun Int.binary(): String {
4 | val binaryInt = Integer.toBinaryString(this)
5 | if (binaryInt.length >= 32) return binaryInt
6 |
7 | val sb = StringBuilder(32)
8 | repeat(32 - binaryInt.length) {
9 | sb.append('0')
10 | }
11 | sb.append(binaryInt)
12 |
13 | return sb.toString()
14 | }
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Jackson.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import com.fasterxml.jackson.databind.JsonNode
4 |
5 | fun JsonNode.isEmpty(): Boolean = this.size() == 0
6 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/JavaFx.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import javafx.scene.paint.Color
4 |
5 | fun Color.toHexFormat(): String {
6 | val r = Math.round(red * 255.0).toInt()
7 | val g = Math.round(green * 255.0).toInt()
8 | val b = Math.round(blue * 255.0).toInt()
9 | val o = Math.round(opacity * 255.0).toInt()
10 | return String.format("#%02x%02x%02x%02x", r, g, b, o)
11 | }
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Json.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import com.fasterxml.jackson.core.JsonParser
4 | import com.fasterxml.jackson.core.JsonToken
5 | import com.fasterxml.jackson.databind.DeserializationFeature
6 | import com.fasterxml.jackson.databind.ObjectMapper
7 | import com.fasterxml.jackson.module.kotlin.KotlinModule
8 |
9 | fun objectMapper(): ObjectMapper = ObjectMapper()
10 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
11 | .registerModule(KotlinModule())
12 |
13 | /**
14 | * Read the next token and assert that it is not null.
15 | * @throws [UnexpectedJsonFormatException] if next token is null.
16 | * */
17 | fun JsonParser.nextNonNullToken(): JsonToken {
18 | return nextToken() ?: throw UnexpectedJsonFormatException("Failed to get next token, end of data stream")
19 | }
20 |
21 | fun JsonToken.validate(expected: JsonToken): JsonToken {
22 | if (this != expected) {
23 | throw UnexpectedJsonFormatException("Expected '$expected' json token, got '$this'")
24 | }
25 | return this
26 | }
27 |
28 | /**
29 | * Read next non-null token and assert that it's value is equal to [expected] token. Blocking operation.
30 | * @throws [UnexpectedJsonFormatException] if next token is null or doesn't equal to [expected] token.
31 | * */
32 | fun JsonParser.expect(expected: JsonToken): JsonToken {
33 | return nextNonNullToken().validate(expected)
34 | }
35 |
36 | class UnexpectedJsonFormatException : Exception {
37 | constructor() : super()
38 | constructor(message: String?) : super(message)
39 | constructor(message: String?, cause: Throwable?) : super(message, cause)
40 | constructor(cause: Throwable?) : super(cause)
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Lang.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | inline fun whileNotNull(supplier: () -> T, operation: (T) -> Unit) {
4 | var value = supplier.invoke()
5 | while (value != null) {
6 | operation.invoke(value)
7 | value = supplier.invoke()
8 | }
9 | }
10 |
11 | fun Collection<*>?.isNullOrEmpty(): Boolean {
12 | if (this == null) return true
13 | if (this.isEmpty()) return true
14 | return false
15 | }
16 |
17 | /** Useful for `when` statement with sealed classes. */
18 | object Do {
19 | inline infix fun exhaustive(any: T?) = any
20 | }
21 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/LateinitVal.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | /** Thread safe lateinit value. */
4 | class LateinitVal {
5 |
6 | private var v: T? = null
7 |
8 | /**
9 | * Get the value.
10 | * @return null if value is is not initialized yet.
11 | * */
12 | fun get(): T? {
13 | if (v != null) return v
14 |
15 | // values is not initialized or not published
16 | synchronized(this) {
17 | return v
18 | }
19 | }
20 |
21 | /**
22 | * Set the value.
23 | * @throws [IllegalStateException] if value already initialized.
24 | * */
25 | fun set(value: T) {
26 | synchronized(this) {
27 | if (v != null) throw IllegalStateException("Value already initialized")
28 | v = value
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/OkHttp.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import failchat.exception.UnexpectedResponseCodeException
4 | import failchat.exception.UnexpectedResponseException
5 | import okhttp3.Call
6 | import okhttp3.Callback
7 | import okhttp3.MediaType
8 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
9 | import okhttp3.RequestBody
10 | import okhttp3.Response
11 | import okhttp3.ResponseBody
12 | import java.io.IOException
13 | import java.util.concurrent.CompletableFuture
14 | import kotlin.coroutines.resume
15 | import kotlin.coroutines.resumeWithException
16 | import kotlin.coroutines.suspendCoroutine
17 |
18 | val jsonMediaType: MediaType = "application/json".toMediaTypeOrNull()!!
19 | val textMediaType: MediaType = "text/plain".toMediaTypeOrNull()!!
20 | val emptyBody: RequestBody = RequestBody.create(textMediaType, "")
21 |
22 | fun Call.toFuture(): CompletableFuture {
23 | val future = CompletableFuture()
24 | this.enqueue(object : Callback {
25 | override fun onFailure(call: Call, e: IOException) {
26 | future.completeExceptionally(e)
27 | }
28 |
29 | override fun onResponse(call: Call, response: Response) {
30 | future.complete(response)
31 | }
32 | })
33 | return future
34 | }
35 |
36 | suspend fun Call.await(): Response {
37 | return suspendCoroutine { continuation ->
38 | this.enqueue(object : Callback {
39 | override fun onResponse(call: Call, response: Response) {
40 | continuation.resume(response)
41 | }
42 |
43 | override fun onFailure(call: Call, e: IOException) {
44 | continuation.resumeWithException(e)
45 | }
46 | })
47 | }
48 | }
49 |
50 | fun Response.getBodyIfStatusIs(expectedStatus: Int): Response {
51 | if (this.code != expectedStatus) {
52 | throw UnexpectedResponseCodeException(this.code, request.url.toString())
53 | }
54 | return this
55 | }
56 |
57 | val Response.nonNullBody: ResponseBody
58 | get() = this.body ?: throw UnexpectedResponseException("null body")
59 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/OkHttpLogger.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import mu.KotlinLogging
4 | import okhttp3.logging.HttpLoggingInterceptor
5 |
6 | object OkHttpLogger : HttpLoggingInterceptor.Logger {
7 |
8 | private val logger = KotlinLogging.logger { }
9 |
10 | override fun log(message: String) {
11 | logger.debug(message)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Streams.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import java.util.stream.Stream
4 |
5 | fun Stream.filterNotNull(): Stream {
6 | @Suppress("UNCHECKED_CAST")
7 | return filter { it != null } as Stream
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Strings.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | fun String.withPrefix(prefix: String): String {
4 | if (this.startsWith(prefix)) return this
5 | return prefix + this
6 | }
7 |
8 | fun String.withSuffix(suffix: String): String {
9 | if (this.endsWith(suffix)) return this
10 | return this + suffix
11 | }
12 |
13 | fun String?.notEmptyOrNull(): String? {
14 | if (this.isNullOrEmpty()) return null
15 | return this
16 | }
17 |
18 | fun String.endsWithAny(suffixes: Iterable): Boolean {
19 | return suffixes.any { suffix ->
20 | this.endsWith(suffix)
21 | }
22 | }
23 |
24 | fun Int.toHexString(): String = java.lang.Integer.toHexString(this)
25 |
26 | fun toCodePoint(high: Char, low: Char): Int = java.lang.Character.toCodePoint(high, low)
27 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Systems.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | /**
4 | * Line separator shortcut.
5 | * */
6 | inline val ls: String get() = System.lineSeparator()
7 |
8 | /**
9 | * Shortcut for [System.getProperty].
10 | * */
11 | fun sp(key: String): String? = System.getProperty(key)
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Threads.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | val hotspotThreads: Set = setOf("Attach Listener", "Disposer", "Finalizer", "Prism Font Disposer",
4 | "Reference Handler", "Signal Dispatcher","DestroyJavaVM")
5 |
6 | fun formatStackTraces(stackTraces: Map>): String {
7 | return stackTraces
8 | .map { (thread, stackTraceElements) ->
9 | with(thread) {
10 | "Thread[name=$name; id=$id; state=$state; isDaemon=$isDaemon; isInterrupted=$isInterrupted; " +
11 | "priority=$priority; threadGroup=${threadGroup.name}]"
12 | } +
13 | if (stackTraceElements.isEmpty()) ""
14 | else stackTraceElements.joinToString(prefix = ls + "\t", separator = ls + "\t")
15 | }
16 | .joinToString(separator = ls)
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/Urls.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import java.util.regex.Pattern
4 |
5 | /**
6 | * URL pattern.
7 | *
8 | * Capture groups:
9 | * 1. protocol (http, https, ftp, ftps)
10 | * 2. "www."
11 | * 3. short url
12 | * 4. domain
13 | */
14 | val urlPattern: Pattern = Pattern.compile("""\b(https?|ftps?)://(w{3}\.)?(([-\w\d+&@#%?=~_|!:,.;]+)[/\S]*)""")
15 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/CountableOrigins.kt:
--------------------------------------------------------------------------------
1 | package failchat.viewers
2 |
3 | import failchat.Origin
4 | import failchat.Origin.GOODGAME
5 | import failchat.Origin.TWITCH
6 | import failchat.Origin.YOUTUBE
7 | import java.util.EnumSet
8 |
9 | val COUNTABLE_ORIGINS: Set = EnumSet.of(GOODGAME, TWITCH, YOUTUBE)
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/ViewersCountLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.viewers
2 |
3 | import failchat.Origin
4 | import java.util.concurrent.CompletableFuture
5 |
6 | interface ViewersCountLoader {
7 | val origin: Origin
8 | fun loadViewersCount(): CompletableFuture
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/viewers/ViewersCountWsHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.viewers
2 |
3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
4 | import failchat.ws.server.InboundWsMessage
5 | import failchat.ws.server.WsMessageHandler
6 | import io.ktor.http.cio.websocket.Frame
7 | import org.apache.commons.configuration2.Configuration
8 |
9 |
10 | class ViewersCountWsHandler(
11 | private val config: Configuration
12 | ) : WsMessageHandler {
13 |
14 | override val expectedType = InboundWsMessage.Type.VIEWERS_COUNT
15 |
16 | @Volatile
17 | var viewersCounter: ViewersCounter? = null
18 |
19 | private val nodeFactory: JsonNodeFactory = JsonNodeFactory.instance
20 |
21 |
22 | override fun handle(message: InboundWsMessage) {
23 | viewersCounter?.let {
24 | it.sendViewersCountWsMessage()
25 | return
26 | }
27 |
28 | // viewersCounter is not set yet
29 | // send message with null values for enabled origins
30 | val enabledOrigins = COUNTABLE_ORIGINS.filter {
31 | config.getBoolean("${it.commonName}.enabled")
32 | }
33 |
34 | val messageNode = nodeFactory.objectNode().apply {
35 | put("type", "viewers-count")
36 | putObject("content").apply {
37 | enabledOrigins.forEach { putNull(it.commonName) }
38 | }
39 | }
40 |
41 | message.session.outgoing.offer(Frame.Text(messageNode.toString()))
42 | }
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/ClientConfigurationWsHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import failchat.chat.ChatMessageSender
4 |
5 | class ClientConfigurationWsHandler(
6 | private val messageSender: ChatMessageSender
7 | ) : WsMessageHandler {
8 |
9 | override val expectedType = InboundWsMessage.Type.CLIENT_CONFIGURATION
10 |
11 | override fun handle(message: InboundWsMessage) {
12 | messageSender.sendClientConfiguration()
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/DeleteWsMessageHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import failchat.chat.ChatMessageRemover
4 |
5 | class DeleteWsMessageHandler(
6 | private val chatMessageRemover: ChatMessageRemover
7 | ) : WsMessageHandler {
8 |
9 | override val expectedType = InboundWsMessage.Type.DELETE_MESSAGE
10 |
11 | override fun handle(message: InboundWsMessage) {
12 | chatMessageRemover.remove(message.content.get("messageId").asLong())
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/IgnoreWsMessageHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import failchat.ConfigKeys
4 | import failchat.chat.handlers.IgnoreFilter
5 | import org.apache.commons.configuration2.Configuration
6 |
7 | class IgnoreWsMessageHandler(
8 | private val ignoreFilter: IgnoreFilter,
9 | private val config: Configuration
10 | ) : WsMessageHandler {
11 |
12 | override val expectedType = InboundWsMessage.Type.IGNORE_AUTHOR
13 |
14 | override fun handle(message: InboundWsMessage) {
15 | val authorId = message.content.get("authorId").asText()
16 | val updatedIgnoreSet = config.getStringArray(ConfigKeys.ignore)
17 | .toMutableSet()
18 | .apply { add(authorId) }
19 | config.setProperty(ConfigKeys.ignore, updatedIgnoreSet)
20 |
21 | ignoreFilter.reloadConfig()
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/InboundWsMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import com.fasterxml.jackson.databind.JsonNode
4 | import io.ktor.websocket.DefaultWebSocketServerSession
5 |
6 | class InboundWsMessage(
7 | val type: Type,
8 | val content: JsonNode,
9 | val session: DefaultWebSocketServerSession
10 | ) {
11 |
12 | enum class Type(val jsonRepresentation: String) {
13 | CLIENT_CONFIGURATION("client-configuration"),
14 | DELETE_MESSAGE("delete-message"),
15 | IGNORE_AUTHOR("ignore-author"),
16 | VIEWERS_COUNT("viewers-count"),
17 | ORIGINS_STATUS("origins-status");
18 |
19 | companion object {
20 | private val map = values()
21 | .map { it.jsonRepresentation to it }
22 | .toMap()
23 |
24 | fun from(string: String): Type? = map[string]
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/WsMessageDispatcher.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper
4 | import failchat.util.enumMap
5 | import io.ktor.http.cio.websocket.Frame
6 | import io.ktor.http.cio.websocket.readText
7 | import io.ktor.websocket.DefaultWebSocketServerSession
8 | import kotlinx.coroutines.channels.consumeEach
9 | import mu.KotlinLogging
10 |
11 | class WsMessageDispatcher(
12 | private val objectMapper: ObjectMapper,
13 | handlers: List
14 | ) {
15 |
16 | private companion object {
17 | val logger = KotlinLogging.logger {}
18 | }
19 |
20 | private val handlers: Map = handlers
21 | .map { it.expectedType to it }
22 | .toMap(enumMap())
23 |
24 | suspend fun handleWebSocket(session: DefaultWebSocketServerSession) {
25 | session.incoming.consumeEach { frame ->
26 | if (frame !is Frame.Text) {
27 | logger.warn("Non textual frame received: {}", frame)
28 | return@consumeEach
29 | }
30 |
31 | val frameText = frame.readText()
32 | logger.debug("Message received from a web socket client: {}", frameText)
33 |
34 | val parsedMessage = objectMapper.readTree(frameText)
35 | val typeString: String = parsedMessage.get("type").asText()
36 | val type = InboundWsMessage.Type.from(typeString)
37 | ?: run {
38 | logger.warn("Message received with unknown type '{}'", typeString)
39 | return@consumeEach
40 | }
41 |
42 | handlers[type]
43 | ?.handle(InboundWsMessage(type, parsedMessage.get("content"), session))
44 | ?: logger.warn("Message consumer not found for a message with a type '{}'. Message: {}", type, frameText)
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/WsMessageHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | interface WsMessageHandler {
4 | val expectedType: InboundWsMessage.Type
5 | fun handle(message: InboundWsMessage)
6 | }
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/LiveChatRequest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import com.fasterxml.jackson.databind.node.ArrayNode
4 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
5 | import com.fasterxml.jackson.databind.node.ObjectNode
6 |
7 | data class LiveChatRequest(
8 | val context: Context = Context(),
9 | val continuation: String
10 | ) {
11 | data class Context(
12 | val client: Client = Client(),
13 | val request: Request = Request(),
14 | val user: ObjectNode = JsonNodeFactory.instance.objectNode(),
15 | val clientScreenNonce: String = "MC4xNzQ1MzczNjgyNTc0MTI1"
16 | )
17 |
18 | data class Client(
19 | val hl: String = "en-GB",
20 | val gl: String = "RU",
21 | val visitorData: String = "CgtvaTIycV9CTXMwSSjUiOP5BQ%3D%3D",
22 | val userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:79.0) Gecko/20100101 Firefox/79.0,gzip(gfe)",
23 | val clientName: String = "WEB",
24 | val clientVersion: String = "2.20200814.00.00",
25 | val osName: String = "Windows",
26 | val osVersion: String = "10.0",
27 | val browserName: String = "Firefox",
28 | val browserVersion: String = "79.0",
29 | val screenWidthPoints: Int = 1920,
30 | val screenHeightPoints: Int = 362,
31 | val screenPixelDensity: Int = 1,
32 | val utcOffsetMinutes: Int = 180,
33 | val userInterfaceTheme: String = "USER_INTERFACE_THEME_LIGHT"
34 | )
35 |
36 | data class Request(
37 | val internalExperimentFlags: ArrayNode = JsonNodeFactory.instance.arrayNode(),
38 | val consistencyTokenJars: ArrayNode = JsonNodeFactory.instance.arrayNode()
39 | )
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/LiveChatRequestParameters.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | data class LiveChatRequestParameters(
4 | val videoId: String,
5 | val channelName: String,
6 | val innertubeApiKey: String,
7 | val nextContinuation: String
8 | )
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/MetadataResponse.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | data class MetadataResponse(
4 | val actions: List
5 | ) {
6 |
7 | data class Action(
8 | val updateViewershipAction: UpdateViewershipAction?
9 | )
10 |
11 | data class UpdateViewershipAction(
12 | val viewCount: ViewCount
13 | )
14 |
15 | data class ViewCount(
16 | val videoViewCountRenderer: VideoViewCountRenderer
17 | )
18 |
19 | data class VideoViewCountRenderer(
20 | val viewCount: ViewCountRendered? // is null on 0 viewers
21 | )
22 |
23 | data class ViewCountRendered(
24 | val simpleText: String
25 | )
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/RoleBadges.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.chat.ImageFormat
4 | import failchat.chat.badge.ImageBadge
5 |
6 | object RoleBadges {
7 | val verified = ImageBadge("../_shared/icons/youtube-verified.svg", ImageFormat.VECTOR, "Verified")
8 | val streamer = ImageBadge("../_shared/icons/youtube-streamer.svg", ImageFormat.VECTOR, "Streamer")
9 | val moderator = ImageBadge("../_shared/icons/youtube-moderator.svg", ImageFormat.VECTOR, "Moderator")
10 | }
11 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/StreamStatus.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | enum class StreamStatus {
4 | NOT_STARTED,
5 | ONLINE,
6 | ENDED,
7 | NOT_FOUND
8 | }
9 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/UpdatedMetadataRequest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | data class UpdatedMetadataRequest(
4 | val context: Context,
5 | val videoId: String
6 | ) {
7 |
8 | data class Context(
9 | val client: Client
10 | )
11 |
12 | data class Client(
13 | val clientName: String,
14 | val clientVersion: String
15 | )
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeClientException.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | class YoutubeClientException(
4 | override val message: String? = null,
5 | override val cause: Throwable? = null
6 | ) : RuntimeException()
7 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeColors.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import javafx.scene.paint.Color
4 |
5 | object YoutubeColors {
6 | val streamer: Color = Color.web("#ffd600")
7 | val moderator: Color = Color.web("#5e84f1")
8 | val member: Color = Color.web("#107516")
9 | }
10 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeEmoticon.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.Origin.YOUTUBE
4 | import failchat.chat.ImageFormat
5 | import failchat.emoticon.Emoticon
6 |
7 | class YoutubeEmoticon(
8 | code: String,
9 | override val url: String,
10 | format: ImageFormat
11 | ) : Emoticon(YOUTUBE, code, format)
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeHighlightHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.chat.MessageHandler
4 | import java.util.concurrent.atomic.AtomicReference
5 |
6 | class YoutubeHighlightHandler : MessageHandler {
7 |
8 | private val appealedChannelTitle = AtomicReference()
9 |
10 | override fun handleMessage(message: YoutubeMessage) {
11 | appealedChannelTitle.get()?.let {
12 | if (message.text.contains(it)) {
13 | message.highlighted = true
14 | }
15 | }
16 | }
17 |
18 | fun setChannelTitle(channelTitle: String) {
19 | appealedChannelTitle.set("@$channelTitle")
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.Origin
4 | import failchat.chat.Author
5 | import failchat.chat.ChatMessage
6 |
7 | class YoutubeMessage(
8 | failchatId: Long,
9 | author: Author,
10 | text: String
11 | ) : ChatMessage(failchatId, Origin.YOUTUBE, author, text)
12 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeViewCountParser.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import java.util.regex.Pattern
4 |
5 | class YoutubeViewCountParser {
6 |
7 | private companion object {
8 | val viewCountPattern = Pattern.compile("""(.+) watching now""")!!
9 | }
10 |
11 | /**
12 | * @throws IllegalArgumentException on parse error.
13 | * */
14 | fun parse(viewCount: String): Int {
15 | val m = viewCountPattern.matcher(viewCount)
16 | require(m.matches()) { "Unexpected view count string: $viewCount" }
17 |
18 | val countWithCommas = m.group(1)
19 | return countWithCommas.replace(",", "").toInt()
20 | }
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/YoutubeViewersCountLoader.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.Origin
4 | import failchat.util.LateinitVal
5 | import failchat.viewers.ViewersCountLoader
6 | import kotlinx.coroutines.CoroutineScope
7 | import kotlinx.coroutines.Dispatchers
8 | import kotlinx.coroutines.future.future
9 | import java.util.concurrent.CompletableFuture
10 |
11 | class YoutubeViewersCountLoader(
12 | private val videoId: String,
13 | private val youtubeClient: YoutubeClient
14 | ) : ViewersCountLoader {
15 |
16 | private val lazyInnertubeApiKey = LateinitVal()
17 |
18 | override val origin = Origin.YOUTUBE
19 |
20 | override fun loadViewersCount(): CompletableFuture {
21 | return CoroutineScope(Dispatchers.Default).future {
22 | val key = lazyInnertubeApiKey.get() ?: run {
23 | val newKey = youtubeClient.getNewLiveChatSessionData(videoId).innertubeApiKey
24 | lazyInnertubeApiKey.set(newKey)
25 | newKey
26 | }
27 |
28 | youtubeClient.getViewersCount(videoId, key)
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/main/resources/config/default.properties:
--------------------------------------------------------------------------------
1 | version=${project.version}
2 | # Messaging
3 | lastMessageId=0
4 | # Update checker
5 | release-checker.enabled=true
6 | release-checker.latest-notified-version=v${project.version}
7 | github.api-url=https://api.github.com/
8 | github.user-name=onoderis
9 | github.repository=failchat
10 | # User configuration
11 | goodgame.enabled=false
12 | goodgame.channel=
13 | twitch.enabled=false
14 | twitch.channel=
15 | youtube.enabled=false
16 | youtube.channel=
17 | skin=old_sc2tv
18 | frame=true
19 | on-top=false
20 | show-viewers=false
21 | show-images=false
22 | click-transparency=false
23 | opacity=100
24 | show-origin-badges=true
25 | show-user-badges=true
26 | zoom-percent=100
27 | hide-deleted-messages=false
28 | deleted-message-placeholder=message deleted
29 | show-click-transparency-icon=true
30 | save-message-history=false
31 | native-client.background-color=#000000ff
32 | native-client.hide-messages=false
33 | native-client.colored-nicknames=true
34 | native-client.hide-messages-after=60
35 | native-client.show-status-messages=true
36 | external-client.background-color=#00000000
37 | external-client.colored-nicknames=true
38 | external-client.hide-messages=false
39 | external-client.hide-messages-after=60
40 | external-client.show-status-messages=true
41 | show-hidden-messages=false
42 | reset-configuration=false
43 | # Window positioning
44 | chat.width=350
45 | chat.height=500
46 | chat.x=-1
47 | chat.y=-1
48 | # Gui mode
49 | gui-mode=FULL_GUI
50 | # Urls, etc
51 | twitch.irc-address=irc.chat.twitch.tv
52 | twitch.irc-port=6697
53 | bttv.api-url=https://api.betterttv.net/
54 | frankerfacez.api-url=https://api.frankerfacez.com/v1/
55 | goodgame.ws-url=wss://chat-1.goodgame.ru/chat2/
56 | goodgame.api-url=https://goodgame.ru/api/
57 | goodgame.emoticon-js-url=https://goodgame.ru/js/minified/global.js
58 | goodgame.badge-url=https://static.goodgame.ru/files/icons/
59 | # fx
60 | about.github-repo=https://github.com/onoderis/failchat
61 | about.discord-server=https://discord.gg/4Qs3Y8h
62 |
--------------------------------------------------------------------------------
/src/main/resources/icons/click-transparency-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/click-transparency-mode.png
--------------------------------------------------------------------------------
/src/main/resources/icons/failchat.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/failchat.png
--------------------------------------------------------------------------------
/src/main/resources/icons/funstream.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/funstream.png
--------------------------------------------------------------------------------
/src/main/resources/icons/goodgame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/goodgame.png
--------------------------------------------------------------------------------
/src/main/resources/icons/twitch.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/twitch.png
--------------------------------------------------------------------------------
/src/main/resources/icons/youtube.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/main/resources/icons/youtube.png
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Configs.kt:
--------------------------------------------------------------------------------
1 | package failchat
2 |
3 | import org.apache.commons.configuration2.Configuration
4 | import org.apache.commons.configuration2.PropertiesConfiguration
5 | import org.apache.commons.configuration2.builder.FileBasedConfigurationBuilder
6 | import org.apache.commons.configuration2.builder.fluent.Parameters
7 |
8 | object Configs
9 |
10 | fun loadDefaultConfig() = loadConfig("/config/default.properties")
11 |
12 | private fun loadConfig(resource: String): Configuration {
13 | return FileBasedConfigurationBuilder(PropertiesConfiguration::class.java)
14 | .configure(
15 | Parameters()
16 | .properties()
17 | .setURL(Configs.javaClass.getResource(resource))
18 | .setThrowExceptionOnMissing(true)
19 | )
20 | .configuration
21 | }
22 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Shared.kt:
--------------------------------------------------------------------------------
1 | package failchat
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper
4 | import failchat.util.objectMapper
5 | import io.ktor.client.HttpClient
6 | import io.ktor.client.engine.okhttp.OkHttp
7 | import okhttp3.OkHttpClient
8 | import org.apache.commons.configuration2.Configuration
9 |
10 | val okHttpClient: OkHttpClient = OkHttpClient.Builder()
11 | // .addInterceptor(HttpLoggingInterceptor().also { it.level = HttpLoggingInterceptor.Level.BODY })
12 | .build()
13 |
14 | val ktorClient = HttpClient(OkHttp) {
15 | engine {
16 | preconfigured = okHttpClient
17 | }
18 | }
19 |
20 |
21 | val userHomeConfig: Configuration by lazy { ConfigLoader(failchatHomePath).load() }
22 | val defaultConfig: Configuration by lazy { loadDefaultConfig() }
23 | val testObjectMapper: ObjectMapper = objectMapper()
24 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/Utils.kt:
--------------------------------------------------------------------------------
1 | package failchat
2 |
3 | import failchat.util.await
4 | import okhttp3.Request
5 | import kotlin.test.assertEquals
6 |
7 | object Utils
8 |
9 | fun readResourceAsString(resource: String): String {
10 | val bytes = Utils::class.java.getResourceAsStream(resource)?.readBytes() ?: error("No resource $resource")
11 | return String(bytes)
12 | }
13 |
14 | suspend fun assertRequestToUrlReturns200(url: String) {
15 | val request = Request.Builder().url(url).get().build()
16 | okHttpClient.newCall(request).await().use {
17 | assertEquals(200, it.code)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/chat/handlers/EmojiHandlerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.chat.handlers
2 |
3 | import failchat.Origin
4 | import failchat.chat.Author
5 | import failchat.emoticon.Emoticon
6 | import failchat.okHttpClient
7 | import failchat.youtube.YoutubeMessage
8 | import okhttp3.Request
9 | import org.junit.Test
10 | import kotlin.test.assertEquals
11 |
12 | class EmojiHandlerTest {
13 |
14 | private val emojiHandler = EmojiHandler()
15 |
16 | @Test
17 | fun oneCharacterEmojiTest() = testYtEmojiHandler("""☕""")
18 |
19 | @Test
20 | fun twoCharacterEmojiTest() = testYtEmojiHandler("""😀""")
21 |
22 | @Test
23 | fun threeCharacterEmojiTest() = testYtEmojiHandler("☝\uD83C\uDFFD")
24 |
25 | @Test
26 | fun fourCharacterEmojiTest() = testYtEmojiHandler("👦\uD83C\uDFFD")
27 |
28 | @Test
29 | fun flagTest() = testYtEmojiHandler("🇦🇩")
30 |
31 | private fun testYtEmojiHandler(text: String) {
32 | val message = YoutubeMessage(0, Author("author", Origin.YOUTUBE, "aid"), text)
33 |
34 | emojiHandler.handleMessage(message)
35 |
36 | assertEmojiFound(message)
37 | }
38 |
39 | private fun assertEmojiFound(message: YoutubeMessage) {
40 | val request = Request.Builder()
41 | .get()
42 | .url((message.elements[0] as Emoticon).url)
43 | .build()
44 |
45 | okHttpClient.newCall(request).execute().use { response ->
46 | assertEquals(200, response.code)
47 | }
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/emoticon/FailchatEmoticonScannerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import java.nio.file.Path
4 | import java.nio.file.Paths
5 | import kotlin.test.Test
6 | import kotlin.test.assertNotNull
7 | import kotlin.test.assertNull
8 |
9 | class FailchatEmoticonScannerTest {
10 |
11 | private companion object {
12 | val testDirPath: Path = Paths.get(FailchatEmoticonScannerTest::class.java.getResource("/failchat-emoticons").toURI())
13 | val failchatEmoticonScanner = FailchatEmoticonScanner(testDirPath, "/")
14 | }
15 |
16 | private val scanResult = failchatEmoticonScanner.scan()
17 |
18 | @Test
19 | fun scanJpg(){
20 | assertNotNull(scanResult.find { it.code == "1" })
21 | }
22 |
23 | @Test
24 | fun scanJpeg(){
25 | assertNotNull(scanResult.find { it.code == "2" })
26 | }
27 |
28 | @Test
29 | fun scanPng(){
30 | assertNotNull(scanResult.find { it.code == "3" })
31 | }
32 |
33 | @Test
34 | fun scanSvg(){
35 | assertNotNull(scanResult.find { it.code == "4" })
36 | }
37 |
38 | @Test
39 | fun scanGif(){
40 | assertNotNull(scanResult.find { it.code == "5" })
41 | }
42 |
43 | @Test
44 | fun ignoreUnknownFormat() {
45 | assertNull(scanResult.find { it.code == "unknown-format" })
46 | }
47 |
48 | @Test
49 | fun ignoreUnsupportedFormat() {
50 | assertNull(scanResult.find { it.code == "unsupported-format" })
51 | }
52 |
53 | @Test
54 | fun acceptMultipleDotsInName() {
55 | assertNotNull(scanResult.find { it.code == "so.many.dots" })
56 | }
57 |
58 | @Test
59 | fun acceptUnderscoreInName() {
60 | assertNotNull(scanResult.find { it.code == "underscore_" })
61 | }
62 |
63 | @Test
64 | fun acceptMinusInName() {
65 | assertNotNull(scanResult.find { it.code == "minus-" })
66 | }
67 |
68 | @Test
69 | fun upperCase() {
70 | assertNotNull(scanResult.find { it.code == "UPPERCASE" })
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/emoticon/WordReplacerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import org.junit.Test
4 | import kotlin.test.assertEquals
5 | import kotlin.test.assertSame
6 |
7 | class WordReplacerTest {
8 |
9 | @Test
10 | fun replaceNothingTest() {
11 | val words = listOf("just", "a", "simple", "message")
12 | val initialString = words.joinToString(separator = " ")
13 |
14 | val actualWords = ArrayList()
15 | val resultString = WordReplacer.replace(initialString) {
16 | actualWords.add(it)
17 | ReplaceDecision.Skip
18 | }
19 |
20 | assertSame(initialString, resultString)
21 | assertEquals(words, actualWords)
22 | }
23 |
24 | @Test
25 | fun replaceAllTest() {
26 | val resultString = WordReplacer.replace("just a simple message") {
27 | ReplaceDecision.Replace("42")
28 | }
29 |
30 | assertEquals("42 42 42 42", resultString)
31 | }
32 |
33 | @Test
34 | fun specialCharactersTest() {
35 | val words = listOf(
36 | "!", "@", "#", "$", "%", "^", "&", "*", "(", ")",
37 | "!!", "@@", "##", "$$", "%%", "^^", "&&", "**", "((", "))"
38 | )
39 | val initialString = words.joinToString(separator = " ")
40 |
41 | val resultString = WordReplacer.replace(initialString) {
42 | ReplaceDecision.Replace("1")
43 | }
44 |
45 | assertEquals(words.joinToString(separator = " ") { "1" }, resultString)
46 | }
47 |
48 | @Test
49 | fun lineBreakTest() {
50 | val resultString = WordReplacer.replace("the\nmessage") {
51 | ReplaceDecision.Replace("1")
52 | }
53 |
54 | assertEquals("1\n1", resultString)
55 | }
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/experiment/EmojiLibTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.experiment
2 |
3 | import com.vdurmont.emoji.EmojiParser
4 | import org.junit.Ignore
5 | import org.junit.Test
6 |
7 | @Ignore
8 | class EmojiLibTest {
9 |
10 | @Test
11 | fun test() {
12 | /*
13 | * ☕ -> 2615
14 | * 😀 -> 1f600
15 | * ☝🏽 -> 261d-1f3fd
16 | * 👦🏽 -> 1f466-1f3fd
17 | * todo 🧘♀
18 | * */
19 |
20 | EmojiParser.parseFromUnicode("\uD83D\uDE00") {
21 | println(it.emoji.unicode)
22 | "<>"
23 | }
24 |
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/experiment/OkHttpWsClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.experiment
2 |
3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
4 | import failchat.util.sleep
5 | import okhttp3.OkHttpClient
6 | import okhttp3.Request
7 | import okhttp3.Response
8 | import okhttp3.WebSocket
9 | import okhttp3.WebSocketListener
10 | import okio.ByteString
11 | import org.junit.Ignore
12 | import org.junit.Test
13 | import java.time.Duration
14 |
15 | @Ignore
16 | class OkHttpWsClient {
17 |
18 | @Test
19 | fun tryIt() {
20 | val client = OkHttpClient.Builder()
21 | .retryOnConnectionFailure(true)
22 | .build()
23 |
24 | val reuqest = Request.Builder()
25 | .url("ws://chat.goodgame.ru:8081/chat/websocket")
26 | .build()
27 |
28 | client.newWebSocket(reuqest, Listener())
29 |
30 | sleep(Duration.ofDays(1))
31 | }
32 |
33 | private class Listener : WebSocketListener() {
34 | override fun onOpen(webSocket: WebSocket, response: Response) {
35 | println("onOpen")
36 |
37 | val joinMessage = JsonNodeFactory.instance.objectNode().apply {
38 | put("type", "join")
39 | putObject("data").apply {
40 | put("channel_id", 20296)
41 | put("isHidden", false)
42 | }
43 | }
44 |
45 | webSocket.send(joinMessage.toString())
46 | }
47 |
48 | override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = response.use { println("onFailure $t") }
49 |
50 | override fun onClosing(webSocket: WebSocket, code: Int, reason: String) = println("onClosing $code, $reason")
51 |
52 | override fun onMessage(webSocket: WebSocket, text: String) = println("onMessage: $text")
53 |
54 | override fun onMessage(webSocket: WebSocket, bytes: ByteString) = println("onMessage bytes")
55 |
56 | override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = println("onClosed $code $reason")
57 | }
58 |
59 | }
60 | /*
61 | {"type":"channel_counters","data":{"channel_id":"20296","clients_in_channel":"3","users_in_channel":1}}
62 | * */
63 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/github/GithubClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | import failchat.defaultConfig
4 | import failchat.okHttpClient
5 | import failchat.testObjectMapper
6 | import org.junit.Test
7 |
8 | class GithubClientTest {
9 |
10 | private val githubClient = GithubClient(defaultConfig.getString("github.api-url"), okHttpClient, testObjectMapper)
11 |
12 | @Test
13 | fun lastReleaseTest() {
14 | githubClient.requestLatestRelease(
15 | defaultConfig.getString("github.user-name"),
16 | defaultConfig.getString("github.repository")
17 | )
18 | .join()
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/goodgame/GgApi2ClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.exception.ChannelOfflineException
4 | import failchat.okHttpClient
5 | import failchat.testObjectMapper
6 | import kotlinx.coroutines.runBlocking
7 | import mu.KotlinLogging
8 | import kotlin.test.Test
9 |
10 | class GgApi2ClientTest {
11 |
12 | private companion object {
13 | val logger = KotlinLogging.logger {}
14 | }
15 |
16 | private val client = GgApi2Client(okHttpClient, testObjectMapper)
17 |
18 | @Test
19 | fun requestViewersCountTest() = runBlocking {
20 | try {
21 | val count = client.requestViewersCount("Fotos")
22 | logger.debug("gg viewers count: {}", count)
23 | } catch (ignored: ChannelOfflineException) {
24 | logger.debug("gg channel is offline")
25 | }
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/goodgame/GgApiClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.goodgame
2 |
3 | import failchat.defaultConfig
4 | import failchat.okHttpClient
5 | import failchat.testObjectMapper
6 | import kotlinx.coroutines.runBlocking
7 | import org.junit.Test
8 | import org.slf4j.Logger
9 | import org.slf4j.LoggerFactory
10 | import kotlin.system.measureTimeMillis
11 |
12 | class GgApiClientTest {
13 |
14 | private companion object {
15 | val log: Logger = LoggerFactory.getLogger(GgApiClientTest::class.java)
16 | }
17 |
18 | private val apiClient = GgApiClient(
19 | okHttpClient,
20 | testObjectMapper,
21 | defaultConfig.getString("goodgame.api-url"),
22 | defaultConfig.getString("goodgame.emoticon-js-url")
23 | )
24 |
25 | @Test
26 | fun channelIdTest() = runBlocking {
27 | apiClient.requestChannelId("Miker")
28 | }
29 |
30 | @Test
31 | fun emoticonsRequestTest() = runBlocking {
32 | val t = measureTimeMillis {
33 | val emoticons = apiClient.requestEmoticonList()
34 | log.debug("gg emoticons size: {}", emoticons.size)
35 | }
36 | log.debug("gg emoticons load time: {} ms", t)
37 | }
38 |
39 | @Test
40 | fun requestChannelInfoTest() = runBlocking {
41 | val c = apiClient.requestChannelInfo("Miker")
42 | log.debug("Channel info: {}", c)
43 | }
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/gui/LoadingPopupTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import javafx.application.Application
4 | import javafx.stage.Popup
5 | import javafx.stage.Stage
6 |
7 | class App : Application() {
8 | override fun start(primaryStage: Stage) {
9 | val loadingPopup = Popup()
10 | loadingPopup.scene
11 |
12 |
13 |
14 | loadingPopup.show(primaryStage)
15 | primaryStage.show()
16 |
17 | }
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/gui/SkinConverterTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.gui
2 |
3 | import failchat.skin.Skin
4 | import org.junit.Test
5 | import java.nio.file.Paths
6 | import kotlin.test.assertEquals
7 |
8 | class SkinConverterTest {
9 |
10 | private val skinOne = Skin("one", Paths.get("/skins/one.html"))
11 |
12 | private val skinConverter = SkinConverter(listOf(
13 | skinOne,
14 | Skin("two", Paths.get("/skins/two.html"))
15 | ))
16 |
17 | @Test
18 | fun defaultSkinTest() {
19 | val s = skinConverter.fromString("three")
20 |
21 | assertEquals(skinOne, s)
22 | }
23 |
24 | }
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/BttvApiClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.okHttpClient
4 | import failchat.testObjectMapper
5 | import kotlinx.coroutines.runBlocking
6 | import org.junit.Assert.assertTrue
7 | import org.junit.Test
8 |
9 | class BttvApiClientTest {
10 |
11 | private val client = BttvApiClient(
12 | httpClient = okHttpClient,
13 | apiUrl = "https://api.betterttv.net/",
14 | objectMapper = testObjectMapper,
15 | )
16 |
17 | @Test
18 | fun loadGlobalEmoticons() = runBlocking {
19 | val emoticons = client.loadGlobalEmoticons().join()
20 | assertTrue(emoticons.isNotEmpty())
21 | }
22 |
23 | @Test
24 | fun loadChannelEmoticons() = runBlocking {
25 | val emoticons = client.loadChannelEmoticons("lirik").join()
26 | assertTrue(emoticons.isNotEmpty())
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/FfzApiClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.ConfigKeys
4 | import failchat.defaultConfig
5 | import failchat.okHttpClient
6 | import failchat.testObjectMapper
7 | import kotlinx.coroutines.runBlocking
8 | import mu.KotlinLogging
9 | import org.junit.Test
10 |
11 | class FfzApiClientTest {
12 |
13 | private companion object {
14 | val logger = KotlinLogging.logger {}
15 | }
16 |
17 | private val apiClient = FfzApiClient(
18 | okHttpClient,
19 | testObjectMapper,
20 | defaultConfig.getString(ConfigKeys.frankerfacezApiUrl)
21 | )
22 | private val roomName = "forsen"
23 |
24 | @Test
25 | fun requestEmoticonsTest(): Unit = runBlocking {
26 | val emoticons = apiClient.requestEmoticons(roomName)
27 | logger.info("ffz emoticons for room {} - {}", roomName, emoticons.size)
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/SevenTvApiClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.assertRequestToUrlReturns200
4 | import failchat.okHttpClient
5 | import failchat.testObjectMapper
6 | import io.kotest.assertions.throwables.shouldThrow
7 | import kotlinx.coroutines.runBlocking
8 | import mu.KotlinLogging
9 | import org.junit.Test
10 |
11 | class SevenTvApiClientTest {
12 |
13 | private companion object {
14 | val logger = KotlinLogging.logger {}
15 | }
16 |
17 | private val apiClient = SevenTvApiClient(
18 | okHttpClient,
19 | testObjectMapper
20 | )
21 |
22 | @Test
23 | fun loadGlobalEmoticons() = runBlocking {
24 | val emoticons = apiClient.loadGlobalEmoticons()
25 | logger.info("7tv global emoticons count: {}", emoticons.size)
26 |
27 | assertRequestToUrlReturns200(emoticons.first().url)
28 | }
29 |
30 | @Test
31 | fun loadChannelEmoticons() = runBlocking {
32 | val emoticons = apiClient.loadChannelEmoticons(23161357L) // lirik
33 | logger.info("7tv channel emoticons count: {}", emoticons.size)
34 |
35 | assertRequestToUrlReturns200(emoticons.first().url)
36 | }
37 |
38 | @Test
39 | fun channelNotFoundTest() = runBlocking {
40 | shouldThrow {
41 | apiClient.loadChannelEmoticons(123L)
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/TwitchEmoticonHandlerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.Elements
4 | import io.kotest.matchers.shouldBe
5 | import org.junit.Test
6 |
7 | class TwitchEmoticonHandlerTest {
8 |
9 | private val handler = TwitchEmoticonHandler(TwitchEmotesTagParser())
10 |
11 | @Test
12 | fun longEmoticonCodeTest() {
13 | // Given
14 | val message = TwitchMessage(
15 | id = 0,
16 | author = "",
17 | text = "Kappa 123 Kappa Keepo he",
18 | tags = mapOf(TwitchIrcTags.emotes to "25:0-4,10-14/1902:16-20")
19 | )
20 |
21 | // When
22 | handler.handleMessage(message)
23 |
24 | // Then
25 | message.text shouldBe "${Elements.label(0)} 123 ${Elements.label(1)} ${Elements.label(2)} he"
26 | message.elements.size shouldBe 3
27 | (message.elements[0] as TwitchEmoticon).twitchId shouldBe "25"
28 | (message.elements[0] as TwitchEmoticon).code shouldBe "Kappa"
29 | (message.elements[1] as TwitchEmoticon).twitchId shouldBe "25"
30 | (message.elements[1] as TwitchEmoticon).code shouldBe "Kappa"
31 | (message.elements[2] as TwitchEmoticon).twitchId shouldBe "1902"
32 | (message.elements[2] as TwitchEmoticon).code shouldBe "Keepo"
33 | }
34 |
35 | @Test
36 | fun noEmoticonsTest() {
37 | // Given
38 | val message = TwitchMessage(
39 | id = 0,
40 | author = "",
41 | text = "message",
42 | tags = mapOf(TwitchIrcTags.emotes to "")
43 | )
44 | // When
45 | handler.handleMessage(message)
46 |
47 | // Then
48 | message.elements.size shouldBe 0
49 | message.text shouldBe "message"
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/util/StackTraceFormatTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import org.junit.Ignore
4 | import org.junit.Test
5 | import org.slf4j.Logger
6 | import org.slf4j.LoggerFactory
7 |
8 | @Ignore
9 | class StackTraceFormatTest {
10 |
11 | private companion object {
12 | val log: Logger = LoggerFactory.getLogger(StackTraceFormatTest::class.java)
13 | }
14 |
15 | @Test
16 | fun manualTest() {
17 | log.info(formatStackTraces(Thread.getAllStackTraces()))
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeClientTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.ktorClient
4 | import failchat.testObjectMapper
5 | import io.kotest.matchers.ints.shouldBeGreaterThanOrEqual
6 | import kotlinx.coroutines.runBlocking
7 | import org.junit.Ignore
8 | import org.junit.Test
9 | import kotlin.test.assertTrue
10 |
11 | @Ignore
12 | class YoutubeClientTest {
13 |
14 | private val client = YoutubeClient(
15 | httpClient = ktorClient,
16 | objectMapper = testObjectMapper,
17 | youtubeHtmlParser = YoutubeHtmlParser(objectMapper = testObjectMapper)
18 | )
19 | private val videoId = "jfKfPfyJRdk"
20 |
21 | @Test
22 | fun getViewersCountTest() = runBlocking {
23 | val innertubeApiKey = client.getNewLiveChatSessionData(videoId).innertubeApiKey
24 |
25 | val count = client.getViewersCount(videoId, innertubeApiKey)
26 |
27 | count shouldBeGreaterThanOrEqual 0
28 | println(count)
29 | }
30 |
31 | @Test
32 | fun getMessagesTest() = runBlocking {
33 | val params = client.getNewLiveChatSessionData(videoId)
34 | val response = client.getLiveChatResponse(params)
35 |
36 | assertTrue(response.continuationContents.liveChatContinuation.actions.isNotEmpty())
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeHtmlParserTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.readResourceAsString
4 | import failchat.testObjectMapper
5 | import io.kotest.matchers.shouldBe
6 | import org.junit.Test
7 |
8 | class YoutubeHtmlParserTest {
9 |
10 | private val youtubeHtmlParser = YoutubeHtmlParser(testObjectMapper)
11 |
12 | @Test
13 | fun `should extract innertubeApiKey`() {
14 | // Given
15 | val html = readResourceAsString("/html/live_chat.html")
16 |
17 | // When
18 | val youtubeConfig = youtubeHtmlParser.parseYoutubeConfig(html)
19 | val actual = youtubeHtmlParser.extractInnertubeApiKey(youtubeConfig)
20 |
21 | // Then
22 | actual shouldBe "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
23 | }
24 |
25 | @Test
26 | fun `should extract initial continuation`() {
27 | // Given
28 | val html = readResourceAsString("/html/live_chat.html")
29 |
30 | // When
31 | val initialData = youtubeHtmlParser.parseInitialData(html)
32 | val actual = youtubeHtmlParser.extractInitialContinuation(initialData)
33 |
34 | // Then
35 | actual shouldBe "0ofMyAOqARpeQ2lrcUp3b1lWVU5UU2pSbmExWkROazV5ZGtsSk9IVnRlblJtTUU5M0VnczFjV0Z3TldGUE5HazVRUm9UNnFqZHVRRU5DZ3MxY1dGd05XRlBOR2s1UVNBQ0tBRSUzRCivjJ_s9ebuAjAAOABAAUoVCAEQABgAIABQx6K47fXm7gJYA3gAULXAwuz15u4CWLTkrPfk4u4CggECCASIAQCgAd7Uue315u4C"
36 | }
37 |
38 | @Test
39 | fun `should extract channel name`() {
40 | // Given
41 | val html = readResourceAsString("/html/live_chat.html")
42 |
43 | // When
44 | val initialData = youtubeHtmlParser.parseInitialData(html)
45 | val actual = youtubeHtmlParser.extractChannelName(initialData)
46 |
47 | // Then
48 | actual shouldBe "ChilledCow"
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeViewCountParserTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import io.kotest.matchers.shouldBe
4 | import org.junit.Test
5 |
6 | class YoutubeViewCountParserTest {
7 |
8 | private val youtubeViewCountParser = YoutubeViewCountParser()
9 |
10 | @Test
11 | fun `should parse number less than 1000`() {
12 | // Given
13 | val viewCount = "1,232 watching now"
14 | // When
15 | val actual = youtubeViewCountParser.parse(viewCount)
16 |
17 | // Then
18 | actual shouldBe 1232
19 | }
20 |
21 | @Test
22 | fun `should parse number greater or equals than 1000`() {
23 | // Given
24 | val viewCount = "609 watching now"
25 |
26 | // When
27 | val actual = youtubeViewCountParser.parse(viewCount)
28 |
29 | // Then
30 | actual shouldBe 609
31 | }
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/1.jpg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/2.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/2.jpeg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/3.png
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/4.svg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/4.svg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/5.gif
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/UPPERCASE.JPG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/UPPERCASE.JPG
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/minus-.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/minus-.jpg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/so.many.dots.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/so.many.dots.jpg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/underscore_.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/underscore_.jpg
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/unknown-format:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/unknown-format
--------------------------------------------------------------------------------
/src/test/resources/failchat-emoticons/unsupported-format.img:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/onoderis/failchat/8135d8500b2d9baad4e339d9a2dca694666daedf/src/test/resources/failchat-emoticons/unsupported-format.img
--------------------------------------------------------------------------------
/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker:
--------------------------------------------------------------------------------
1 | mock-maker-inline
2 |
--------------------------------------------------------------------------------