{
57 | return EnumMap(statuses)
58 | }
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/SemicolonCodeProcessor.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import java.util.regex.Pattern
4 |
5 | object SemicolonCodeProcessor {
6 |
7 | private val emoticonCodePattern: Pattern = Pattern.compile("""(?:(?[\w-]+)):""")
8 |
9 | fun process(initialString: String, decisionMaker: (code: String) -> ReplaceDecision): String {
10 | // Can't use Matcher.appendReplacement() because it resets position when Matcher.find(start) invoked
11 | val matcher = emoticonCodePattern.matcher(initialString)
12 | val sb = lazy(LazyThreadSafetyMode.NONE) { StringBuilder() }
13 | var cursor = 0
14 |
15 | while (matcher.find(cursor)) {
16 | val code = matcher.group("code")
17 | val decision = decisionMaker.invoke(code)
18 |
19 | val end = matcher.end()
20 | when (decision) {
21 | is ReplaceDecision.Replace -> {
22 | val appendFrom = if (sb.isInitialized()) cursor else 0
23 | sb.value.append(initialString, appendFrom, matcher.start())
24 | sb.value.append(decision.replacement)
25 | cursor = end
26 | }
27 | is ReplaceDecision.Skip -> {
28 | val lastSemicolonPosition = if (end > 0) end - 1 else end
29 | if (sb.isInitialized()) {
30 | sb.value.append(initialString, cursor, lastSemicolonPosition)
31 | }
32 | cursor = lastSemicolonPosition
33 | }
34 | }
35 | }
36 |
37 | if (!sb.isInitialized()) return initialString
38 |
39 | sb.value.append(initialString, cursor, initialString.length)
40 | return sb.value.toString()
41 | }
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/youtube/LiveChatRequest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import com.fasterxml.jackson.databind.node.ArrayNode
4 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
5 | import com.fasterxml.jackson.databind.node.ObjectNode
6 |
7 | data class LiveChatRequest(
8 | val context: Context = Context(),
9 | val continuation: String
10 | ) {
11 | data class Context(
12 | val client: Client = Client(),
13 | val request: Request = Request(),
14 | val user: ObjectNode = JsonNodeFactory.instance.objectNode(),
15 | val clientScreenNonce: String = "MC4xNzQ1MzczNjgyNTc0MTI1"
16 | )
17 |
18 | data class Client(
19 | val hl: String = "en-GB",
20 | val gl: String = "RU",
21 | val visitorData: String = "CgtvaTIycV9CTXMwSSjUiOP5BQ%3D%3D",
22 | val userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:79.0) Gecko/20100101 Firefox/79.0,gzip(gfe)",
23 | val clientName: String = "WEB",
24 | val clientVersion: String = "2.20200814.00.00",
25 | val osName: String = "Windows",
26 | val osVersion: String = "10.0",
27 | val browserName: String = "Firefox",
28 | val browserVersion: String = "79.0",
29 | val screenWidthPoints: Int = 1920,
30 | val screenHeightPoints: Int = 362,
31 | val screenPixelDensity: Int = 1,
32 | val utcOffsetMinutes: Int = 180,
33 | val userInterfaceTheme: String = "USER_INTERFACE_THEME_LIGHT"
34 | )
35 |
36 | data class Request(
37 | val internalExperimentFlags: ArrayNode = JsonNodeFactory.instance.arrayNode(),
38 | val consistencyTokenJars: ArrayNode = JsonNodeFactory.instance.arrayNode()
39 | )
40 |
41 | }
42 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/twitch/TwitchEmoticonHandlerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.Elements
4 | import io.kotest.matchers.shouldBe
5 | import org.junit.Test
6 |
7 | class TwitchEmoticonHandlerTest {
8 |
9 | private val handler = TwitchEmoticonHandler(TwitchEmotesTagParser())
10 |
11 | @Test
12 | fun longEmoticonCodeTest() {
13 | // Given
14 | val message = TwitchMessage(
15 | id = 0,
16 | author = "",
17 | text = "Kappa 123 Kappa Keepo he",
18 | tags = mapOf(TwitchIrcTags.emotes to "25:0-4,10-14/1902:16-20")
19 | )
20 |
21 | // When
22 | handler.handleMessage(message)
23 |
24 | // Then
25 | message.text shouldBe "${Elements.label(0)} 123 ${Elements.label(1)} ${Elements.label(2)} he"
26 | message.elements.size shouldBe 3
27 | (message.elements[0] as TwitchEmoticon).twitchId shouldBe "25"
28 | (message.elements[0] as TwitchEmoticon).code shouldBe "Kappa"
29 | (message.elements[1] as TwitchEmoticon).twitchId shouldBe "25"
30 | (message.elements[1] as TwitchEmoticon).code shouldBe "Kappa"
31 | (message.elements[2] as TwitchEmoticon).twitchId shouldBe "1902"
32 | (message.elements[2] as TwitchEmoticon).code shouldBe "Keepo"
33 | }
34 |
35 | @Test
36 | fun noEmoticonsTest() {
37 | // Given
38 | val message = TwitchMessage(
39 | id = 0,
40 | author = "",
41 | text = "message",
42 | tags = mapOf(TwitchIrcTags.emotes to "")
43 | )
44 | // When
45 | handler.handleMessage(message)
46 |
47 | // Then
48 | message.elements.size shouldBe 0
49 | message.text shouldBe "message"
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/glass/glass.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | failchat
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
32 |
33 |
34 |

35 |
36 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/youtube/YoutubeHtmlParserTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.youtube
2 |
3 | import failchat.readResourceAsString
4 | import failchat.testObjectMapper
5 | import io.kotest.matchers.shouldBe
6 | import org.junit.Test
7 |
8 | class YoutubeHtmlParserTest {
9 |
10 | private val youtubeHtmlParser = YoutubeHtmlParser(testObjectMapper)
11 |
12 | @Test
13 | fun `should extract innertubeApiKey`() {
14 | // Given
15 | val html = readResourceAsString("/html/live_chat.html")
16 |
17 | // When
18 | val youtubeConfig = youtubeHtmlParser.parseYoutubeConfig(html)
19 | val actual = youtubeHtmlParser.extractInnertubeApiKey(youtubeConfig)
20 |
21 | // Then
22 | actual shouldBe "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8"
23 | }
24 |
25 | @Test
26 | fun `should extract initial continuation`() {
27 | // Given
28 | val html = readResourceAsString("/html/live_chat.html")
29 |
30 | // When
31 | val initialData = youtubeHtmlParser.parseInitialData(html)
32 | val actual = youtubeHtmlParser.extractInitialContinuation(initialData)
33 |
34 | // Then
35 | actual shouldBe "0ofMyAOqARpeQ2lrcUp3b1lWVU5UU2pSbmExWkROazV5ZGtsSk9IVnRlblJtTUU5M0VnczFjV0Z3TldGUE5HazVRUm9UNnFqZHVRRU5DZ3MxY1dGd05XRlBOR2s1UVNBQ0tBRSUzRCivjJ_s9ebuAjAAOABAAUoVCAEQABgAIABQx6K47fXm7gJYA3gAULXAwuz15u4CWLTkrPfk4u4CggECCASIAQCgAd7Uue315u4C"
36 | }
37 |
38 | @Test
39 | fun `should extract channel name`() {
40 | // Given
41 | val html = readResourceAsString("/html/live_chat.html")
42 |
43 | // When
44 | val initialData = youtubeHtmlParser.parseInitialData(html)
45 | val actual = youtubeHtmlParser.extractChannelName(initialData)
46 |
47 | // Then
48 | actual shouldBe "ChilledCow"
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/chat/handlers/EmojiHandler.kt:
--------------------------------------------------------------------------------
1 | package failchat.chat.handlers
2 |
3 | import com.vdurmont.emoji.EmojiParser
4 | import failchat.chat.ChatMessage
5 | import failchat.chat.MessageHandler
6 | import failchat.emoticon.EmojiEmoticon
7 | import failchat.util.toCodePoint
8 | import failchat.util.toHexString
9 |
10 | /**
11 | * Searches for unicode emojis in message and replaces them with svg images.
12 | * */
13 | class EmojiHandler : MessageHandler {
14 |
15 | override fun handleMessage(message: ChatMessage) {
16 | val transformedText = EmojiParser.parseFromUnicode(message.text) {
17 | val hexEmoji = toHex(it.emoji.unicode)
18 | val hexFitzpatrick: String? = it.fitzpatrick?.let { f ->
19 | toHex(f.unicode)
20 | }
21 |
22 | val emojiHexSequence = if (hexFitzpatrick == null) {
23 | hexEmoji
24 | } else {
25 | "$hexEmoji-$hexFitzpatrick"
26 | }
27 |
28 | val emoticonUrl = "https://cdnjs.cloudflare.com/ajax/libs/twemoji/13.0.1/svg/$emojiHexSequence.svg"
29 | val emoticon = EmojiEmoticon(it.emoji.description ?: "emoji", emoticonUrl)
30 |
31 | message.addElement(emoticon)
32 | }
33 |
34 | message.text = transformedText
35 | }
36 |
37 | private fun toHex(emojiCharacters: String): String {
38 | return emojiCharacters
39 | .windowed(2, 2, true) {
40 | when (it.length) {
41 | 1 -> it[0].toInt().toHexString()
42 | 2 -> toCodePoint(it[0], it[1]).toHexString()
43 | else -> error("Expected windows of 1..2 characters: '$emojiCharacters'")
44 | }
45 | }
46 | .joinToString(separator = "-")
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/funstream/funstream.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | failchat
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
32 |
33 |
34 |

35 |
36 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/DeletedMessagePlaceholderFactory.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.ConfigKeys
4 | import failchat.Origin.BTTV_CHANNEL
5 | import failchat.Origin.BTTV_GLOBAL
6 | import failchat.Origin.FAILCHAT
7 | import failchat.Origin.FRANKERFASEZ
8 | import failchat.Origin.GOODGAME
9 | import failchat.Origin.TWITCH
10 | import failchat.chat.DeletedMessagePlaceholder
11 | import failchat.chat.Elements
12 | import org.apache.commons.configuration2.Configuration
13 |
14 | class DeletedMessagePlaceholderFactory(
15 | private val emoticonFinder: EmoticonFinder,
16 | private val config: Configuration
17 | ) {
18 |
19 | private val prefixes = mapOf(
20 | "tw" to TWITCH,
21 | "gg" to GOODGAME,
22 | "fc" to FAILCHAT,
23 | "btg" to BTTV_GLOBAL,
24 | "btc" to BTTV_CHANNEL,
25 | "ffz" to FRANKERFASEZ
26 | )
27 |
28 | fun create(): DeletedMessagePlaceholder {
29 | val text = config.getString(ConfigKeys.deletedMessagePlaceholder)
30 | val emoticons = ArrayList(2)
31 |
32 | val escapedText = text
33 | .let { Elements.escapeBraces(it) }
34 | .let { Elements.escapeLabelCharacters(it) }
35 |
36 | val processedText = SemicolonCodeProcessor.process(escapedText) { code ->
37 | val prefixAndCode = code.split("-", ignoreCase = true, limit = 2)
38 | val origin = prefixes[prefixAndCode.first()]
39 | ?: return@process ReplaceDecision.Skip
40 |
41 | val emoticon = emoticonFinder.findByCode(origin, prefixAndCode[1])
42 | ?: return@process ReplaceDecision.Skip
43 |
44 | val emoticonNo = emoticons.size
45 | val label = Elements.label(emoticonNo)
46 | emoticons.add(emoticon)
47 |
48 | ReplaceDecision.Replace(label)
49 | }
50 |
51 | return DeletedMessagePlaceholder(processedText, emoticons)
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/ws/server/WsMessageDispatcher.kt:
--------------------------------------------------------------------------------
1 | package failchat.ws.server
2 |
3 | import com.fasterxml.jackson.databind.ObjectMapper
4 | import failchat.util.enumMap
5 | import io.ktor.http.cio.websocket.Frame
6 | import io.ktor.http.cio.websocket.readText
7 | import io.ktor.websocket.DefaultWebSocketServerSession
8 | import kotlinx.coroutines.channels.consumeEach
9 | import mu.KotlinLogging
10 |
11 | class WsMessageDispatcher(
12 | private val objectMapper: ObjectMapper,
13 | handlers: List
14 | ) {
15 |
16 | private companion object {
17 | val logger = KotlinLogging.logger {}
18 | }
19 |
20 | private val handlers: Map = handlers
21 | .map { it.expectedType to it }
22 | .toMap(enumMap())
23 |
24 | suspend fun handleWebSocket(session: DefaultWebSocketServerSession) {
25 | session.incoming.consumeEach { frame ->
26 | if (frame !is Frame.Text) {
27 | logger.warn("Non textual frame received: {}", frame)
28 | return@consumeEach
29 | }
30 |
31 | val frameText = frame.readText()
32 | logger.debug("Message received from a web socket client: {}", frameText)
33 |
34 | val parsedMessage = objectMapper.readTree(frameText)
35 | val typeString: String = parsedMessage.get("type").asText()
36 | val type = InboundWsMessage.Type.from(typeString)
37 | ?: run {
38 | logger.warn("Message received with unknown type '{}'", typeString)
39 | return@consumeEach
40 | }
41 |
42 | handlers[type]
43 | ?.handle(InboundWsMessage(type, parsedMessage.get("content"), session))
44 | ?: logger.warn("Message consumer not found for a message with a type '{}'. Message: {}", type, frameText)
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/resources/config/default.properties:
--------------------------------------------------------------------------------
1 | version=${project.version}
2 | # Messaging
3 | lastMessageId=0
4 | # Update checker
5 | release-checker.enabled=true
6 | release-checker.latest-notified-version=v${project.version}
7 | github.api-url=https://api.github.com/
8 | github.user-name=onoderis
9 | github.repository=failchat
10 | # User configuration
11 | goodgame.enabled=false
12 | goodgame.channel=
13 | twitch.enabled=false
14 | twitch.channel=
15 | youtube.enabled=false
16 | youtube.channel=
17 | skin=old_sc2tv
18 | frame=true
19 | on-top=false
20 | show-viewers=false
21 | show-images=false
22 | click-transparency=false
23 | opacity=100
24 | show-origin-badges=true
25 | show-user-badges=true
26 | zoom-percent=100
27 | hide-deleted-messages=false
28 | deleted-message-placeholder=message deleted
29 | show-click-transparency-icon=true
30 | save-message-history=false
31 | native-client.background-color=#000000ff
32 | native-client.hide-messages=false
33 | native-client.colored-nicknames=true
34 | native-client.hide-messages-after=60
35 | native-client.show-status-messages=true
36 | external-client.background-color=#00000000
37 | external-client.colored-nicknames=true
38 | external-client.hide-messages=false
39 | external-client.hide-messages-after=60
40 | external-client.show-status-messages=true
41 | show-hidden-messages=false
42 | reset-configuration=false
43 | # Window positioning
44 | chat.width=350
45 | chat.height=500
46 | chat.x=-1
47 | chat.y=-1
48 | # Gui mode
49 | gui-mode=FULL_GUI
50 | # Urls, etc
51 | twitch.irc-address=irc.chat.twitch.tv
52 | twitch.irc-port=6697
53 | bttv.api-url=https://api.betterttv.net/
54 | frankerfacez.api-url=https://api.frankerfacez.com/v1/
55 | goodgame.ws-url=wss://chat-1.goodgame.ru/chat2/
56 | goodgame.api-url=https://goodgame.ru/api/
57 | goodgame.emoticon-js-url=https://goodgame.ru/js/minified/global.js
58 | goodgame.badge-url=https://static.goodgame.ru/files/icons/
59 | # fx
60 | about.github-repo=https://github.com/onoderis/failchat
61 | about.discord-server=https://discord.gg/4Qs3Y8h
62 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/platform/windows/WindowsCtConfigurator.kt:
--------------------------------------------------------------------------------
1 | package failchat.platform.windows
2 |
3 | import com.sun.jna.platform.win32.WinDef.HWND
4 | import failchat.ConfigKeys
5 | import failchat.gui.ClickTransparencyConfigurator
6 | import javafx.stage.Stage
7 | import javafx.stage.StageStyle.DECORATED
8 | import javafx.stage.StageStyle.TRANSPARENT
9 | import mu.KotlinLogging
10 | import org.apache.commons.configuration2.Configuration
11 |
12 | class WindowsCtConfigurator(private val config: Configuration) : ClickTransparencyConfigurator {
13 |
14 | private companion object {
15 | val logger = KotlinLogging.logger {}
16 | }
17 |
18 | override fun configureClickTransparency(stage: Stage) {
19 | if (!config.getBoolean(ConfigKeys.clickTransparency)) return
20 |
21 | val handle = getWindowHandle(stage) ?: return
22 |
23 | try {
24 | Windows.makeWindowClickTransparent(handle)
25 | } catch (t: Throwable) {
26 | logger.error("Failed to make clicks transparent for {} frame", stage.style, t)
27 | }
28 | }
29 |
30 | override fun removeClickTransparency(stage: Stage) {
31 | val handle = getWindowHandle(stage) ?: return
32 |
33 | try {
34 | val removeLayeredStyle = when (stage.style) {
35 | DECORATED -> true
36 | TRANSPARENT -> false
37 | else -> throw IllegalArgumentException("StageStyle: ${stage.style}")
38 | }
39 |
40 | Windows.makeWindowClickOpaque(handle, removeLayeredStyle)
41 | } catch (t: Throwable) {
42 | logger.error("Failed to make clicks opaque for {} frame", stage.style, t)
43 | }
44 | }
45 |
46 | private fun getWindowHandle(stage: Stage): HWND? {
47 | return try {
48 | Windows.getWindowHandle(stage)
49 | } catch (t: Throwable) {
50 | logger.error("Failed to get handle for {} window", stage.style, t)
51 | null
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/emoticon/FailchatEmoticonScannerTest.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import java.nio.file.Path
4 | import java.nio.file.Paths
5 | import kotlin.test.Test
6 | import kotlin.test.assertNotNull
7 | import kotlin.test.assertNull
8 |
9 | class FailchatEmoticonScannerTest {
10 |
11 | private companion object {
12 | val testDirPath: Path = Paths.get(FailchatEmoticonScannerTest::class.java.getResource("/failchat-emoticons").toURI())
13 | val failchatEmoticonScanner = FailchatEmoticonScanner(testDirPath, "/")
14 | }
15 |
16 | private val scanResult = failchatEmoticonScanner.scan()
17 |
18 | @Test
19 | fun scanJpg(){
20 | assertNotNull(scanResult.find { it.code == "1" })
21 | }
22 |
23 | @Test
24 | fun scanJpeg(){
25 | assertNotNull(scanResult.find { it.code == "2" })
26 | }
27 |
28 | @Test
29 | fun scanPng(){
30 | assertNotNull(scanResult.find { it.code == "3" })
31 | }
32 |
33 | @Test
34 | fun scanSvg(){
35 | assertNotNull(scanResult.find { it.code == "4" })
36 | }
37 |
38 | @Test
39 | fun scanGif(){
40 | assertNotNull(scanResult.find { it.code == "5" })
41 | }
42 |
43 | @Test
44 | fun ignoreUnknownFormat() {
45 | assertNull(scanResult.find { it.code == "unknown-format" })
46 | }
47 |
48 | @Test
49 | fun ignoreUnsupportedFormat() {
50 | assertNull(scanResult.find { it.code == "unsupported-format" })
51 | }
52 |
53 | @Test
54 | fun acceptMultipleDotsInName() {
55 | assertNotNull(scanResult.find { it.code == "so.many.dots" })
56 | }
57 |
58 | @Test
59 | fun acceptUnderscoreInName() {
60 | assertNotNull(scanResult.find { it.code == "underscore_" })
61 | }
62 |
63 | @Test
64 | fun acceptMinusInName() {
65 | assertNotNull(scanResult.find { it.code == "minus-" })
66 | }
67 |
68 | @Test
69 | fun upperCase() {
70 | assertNotNull(scanResult.find { it.code == "UPPERCASE" })
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/src/main/external-resources/skins/_shared/icons/click-transparency-mode.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/EmoticonStorage.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin
4 | import failchat.util.enumMap
5 | import kotlinx.coroutines.flow.Flow
6 | import mu.KotlinLogging
7 |
8 | class EmoticonStorage : EmoticonFinder {
9 |
10 | private companion object {
11 | val logger = KotlinLogging.logger {}
12 | }
13 |
14 | private var originStorages: Map = Origin.values
15 | .map { it to EmptyEmoticonStorage(it) }
16 | .toMap(enumMap())
17 |
18 | fun setStorages(storages: List) {
19 | originStorages = Origin.values.minus(storages.map { it.origin })
20 | .map { EmptyEmoticonStorage(it) }
21 | .plus(storages)
22 | .map { it.origin to it }
23 | .toMap(enumMap())
24 | }
25 |
26 | override fun findByCode(origin: Origin, code: String): Emoticon? {
27 | return originStorages
28 | .get(origin)!!
29 | .findByCode(code)
30 | }
31 |
32 | override fun findById(origin: Origin, id: String): Emoticon? {
33 | return originStorages
34 | .get(origin)!!
35 | .findById(id)
36 | }
37 |
38 | override fun getAll(origin: Origin): Collection {
39 | return originStorages
40 | .get(origin)!!
41 | .getAll()
42 | }
43 |
44 | fun getCount(origin: Origin): Int {
45 | return originStorages.get(origin)!!.count()
46 | }
47 |
48 | fun putMapping(origin: Origin, emoticons: Collection) {
49 | originStorages.get(origin)!!
50 | .putAll(emoticons)
51 | }
52 |
53 | fun putChannel(origin: Origin, emoticons: Flow) {
54 | originStorages.get(origin)!!.putAll(emoticons)
55 | }
56 |
57 | fun clear(origin: Origin) {
58 | originStorages.get(origin)!!.clear()
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/OkHttp.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import failchat.exception.UnexpectedResponseCodeException
4 | import failchat.exception.UnexpectedResponseException
5 | import okhttp3.Call
6 | import okhttp3.Callback
7 | import okhttp3.MediaType
8 | import okhttp3.MediaType.Companion.toMediaTypeOrNull
9 | import okhttp3.RequestBody
10 | import okhttp3.Response
11 | import okhttp3.ResponseBody
12 | import java.io.IOException
13 | import java.util.concurrent.CompletableFuture
14 | import kotlin.coroutines.resume
15 | import kotlin.coroutines.resumeWithException
16 | import kotlin.coroutines.suspendCoroutine
17 |
18 | val jsonMediaType: MediaType = "application/json".toMediaTypeOrNull()!!
19 | val textMediaType: MediaType = "text/plain".toMediaTypeOrNull()!!
20 | val emptyBody: RequestBody = RequestBody.create(textMediaType, "")
21 |
22 | fun Call.toFuture(): CompletableFuture {
23 | val future = CompletableFuture()
24 | this.enqueue(object : Callback {
25 | override fun onFailure(call: Call, e: IOException) {
26 | future.completeExceptionally(e)
27 | }
28 |
29 | override fun onResponse(call: Call, response: Response) {
30 | future.complete(response)
31 | }
32 | })
33 | return future
34 | }
35 |
36 | suspend fun Call.await(): Response {
37 | return suspendCoroutine { continuation ->
38 | this.enqueue(object : Callback {
39 | override fun onResponse(call: Call, response: Response) {
40 | continuation.resume(response)
41 | }
42 |
43 | override fun onFailure(call: Call, e: IOException) {
44 | continuation.resumeWithException(e)
45 | }
46 | })
47 | }
48 | }
49 |
50 | fun Response.getBodyIfStatusIs(expectedStatus: Int): Response {
51 | if (this.code != expectedStatus) {
52 | throw UnexpectedResponseCodeException(this.code, request.url.toString())
53 | }
54 | return this
55 | }
56 |
57 | val Response.nonNullBody: ResponseBody
58 | get() = this.body ?: throw UnexpectedResponseException("null body")
59 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/chat/ChatMessage.kt:
--------------------------------------------------------------------------------
1 | package failchat.chat
2 |
3 | import failchat.Origin
4 | import failchat.chat.badge.Badge
5 | import failchat.emoticon.Emoticon
6 | import java.time.Instant
7 |
8 | /**
9 | * Сообщение из чата какого-либо первоисточника.
10 | * */
11 | open class ChatMessage(
12 | /** Внутренний id, генерируемый приложением. */
13 | val id: Long,
14 |
15 | /** Первоисточник сообщения. */
16 | val origin: Origin,
17 |
18 | /** Автор сообщения. */
19 | val author: Author,
20 |
21 | /** Текст сообщения. */
22 | var text: String,
23 |
24 | /** Время получения сообщения. */
25 | val timestamp: Instant = Instant.now()
26 | ) {
27 |
28 | /**
29 | * Could contains next elements:
30 | * - [Emoticon]
31 | * - [Link]
32 | * - [Image]
33 | * */
34 | val elements: List
35 | get() = mutableElements
36 | private val mutableElements: MutableList = ArrayList(5)
37 |
38 | /** Badges of the message. */
39 | val badges: List
40 | get() = mutableBadges
41 | private val mutableBadges: MutableList = ArrayList(3)
42 |
43 | var highlighted: Boolean = false
44 | var highlightedBackground: Boolean = false
45 |
46 | /**
47 | * @return formatted string for added element.
48 | * */
49 | fun addElement(element: MessageElement): String {
50 | mutableElements.add(element)
51 | return Elements.label(mutableElements.size - 1)
52 | }
53 |
54 | fun replaceElement(index: Int, replacement: MessageElement): Any? {
55 | return mutableElements.set(index, replacement)
56 | }
57 |
58 | fun addBadge(badge: Badge) {
59 | mutableBadges.add(badge)
60 | }
61 |
62 | override fun toString(): String {
63 | return "ChatMessage(id=$id, origin=$origin, author=$author, text='$text', timestamp=$timestamp, " +
64 | "badges=$badges, mutableElements=$mutableElements, highlighted=$highlighted, " +
65 | "highlightedBackground=$highlightedBackground)"
66 | }
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/FailchatEmoticonScanner.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.chat.ImageFormat.RASTER
4 | import failchat.chat.ImageFormat.VECTOR
5 | import failchat.util.filterNotNull
6 | import failchat.util.withSuffix
7 | import mu.KotlinLogging
8 | import java.nio.file.Files
9 | import java.nio.file.Path
10 | import java.time.Duration
11 | import java.time.Instant
12 | import java.util.regex.Pattern
13 | import java.util.stream.Collectors
14 |
15 | class FailchatEmoticonScanner(
16 | private val emoticonsDirectory: Path,
17 | locationUrlPrefix: String
18 | ) {
19 |
20 | private val locationUrlPrefix = locationUrlPrefix.withSuffix("/")
21 |
22 | private companion object {
23 | val logger = KotlinLogging.logger {}
24 | val fileNamePattern: Pattern = Pattern.compile("""(?.+)\.(?jpe?g|png|gif|svg)$""", Pattern.CASE_INSENSITIVE)
25 | }
26 |
27 | fun scan(): List {
28 | val t1 = Instant.now()
29 | val emoticons = Files.list(emoticonsDirectory)
30 | .map { it.fileName.toString() }
31 | .map { fileName ->
32 | val m = fileNamePattern.matcher(fileName)
33 | if (!m.matches()) {
34 | logger.warn("Incorrect failchat emoticon file was ignored: '{}'", fileName)
35 | return@map null
36 | }
37 |
38 | Triple(fileName, m.group("code"), m.group("format"))
39 | }
40 | .filterNotNull()
41 | .map { (fileName, code, formatStr) ->
42 | val format = when (formatStr.toLowerCase()) {
43 | "svg" -> VECTOR
44 | else -> RASTER
45 | }
46 | FailchatEmoticon(code, format, locationUrlPrefix + fileName)
47 | }
48 | .collect(Collectors.toList())
49 |
50 | val t2 = Instant.now()
51 | logger.debug { "Failchat emoticons was scanned in ${Duration.between(t1, t2).toMillis()} ms" }
52 |
53 | return emoticons
54 | }
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/src/test/kotlin/failchat/experiment/OkHttpWsClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.experiment
2 |
3 | import com.fasterxml.jackson.databind.node.JsonNodeFactory
4 | import failchat.util.sleep
5 | import okhttp3.OkHttpClient
6 | import okhttp3.Request
7 | import okhttp3.Response
8 | import okhttp3.WebSocket
9 | import okhttp3.WebSocketListener
10 | import okio.ByteString
11 | import org.junit.Ignore
12 | import org.junit.Test
13 | import java.time.Duration
14 |
15 | @Ignore
16 | class OkHttpWsClient {
17 |
18 | @Test
19 | fun tryIt() {
20 | val client = OkHttpClient.Builder()
21 | .retryOnConnectionFailure(true)
22 | .build()
23 |
24 | val reuqest = Request.Builder()
25 | .url("ws://chat.goodgame.ru:8081/chat/websocket")
26 | .build()
27 |
28 | client.newWebSocket(reuqest, Listener())
29 |
30 | sleep(Duration.ofDays(1))
31 | }
32 |
33 | private class Listener : WebSocketListener() {
34 | override fun onOpen(webSocket: WebSocket, response: Response) {
35 | println("onOpen")
36 |
37 | val joinMessage = JsonNodeFactory.instance.objectNode().apply {
38 | put("type", "join")
39 | putObject("data").apply {
40 | put("channel_id", 20296)
41 | put("isHidden", false)
42 | }
43 | }
44 |
45 | webSocket.send(joinMessage.toString())
46 | }
47 |
48 | override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) = response.use { println("onFailure $t") }
49 |
50 | override fun onClosing(webSocket: WebSocket, code: Int, reason: String) = println("onClosing $code, $reason")
51 |
52 | override fun onMessage(webSocket: WebSocket, text: String) = println("onMessage: $text")
53 |
54 | override fun onMessage(webSocket: WebSocket, bytes: ByteString) = println("onMessage bytes")
55 |
56 | override fun onClosed(webSocket: WebSocket, code: Int, reason: String) = println("onClosed $code $reason")
57 | }
58 |
59 | }
60 | /*
61 | {"type":"channel_counters","data":{"channel_id":"20296","clients_in_channel":"3","users_in_channel":1}}
62 | * */
63 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/util/CompletableFutures.kt:
--------------------------------------------------------------------------------
1 | package failchat.util
2 |
3 | import mu.KotlinLogging
4 | import java.time.Duration
5 | import java.util.concurrent.CompletableFuture
6 | import java.util.concurrent.CompletionException
7 | import java.util.concurrent.CompletionStage
8 | import java.util.concurrent.ExecutionException
9 | import java.util.concurrent.TimeUnit
10 |
11 | private val logger = KotlinLogging.logger {}
12 |
13 | fun Collection>.compose(): CompletableFuture {
14 | return CompletableFuture.allOf(*this.toTypedArray()) // excessive array copying here because of spread operator
15 | }
16 |
17 | fun CompletableFuture.get(timeout: Duration): T = this.get(timeout.toMillis(), TimeUnit.MILLISECONDS)
18 |
19 | fun completedFuture(value: T): CompletableFuture = CompletableFuture.completedFuture(value)
20 | fun completedFuture(): CompletableFuture = CompletableFuture.completedFuture(Unit)
21 |
22 | fun exceptionalFuture(exception: Throwable): CompletableFuture {
23 | return CompletableFuture().apply { completeExceptionally(exception) }
24 | }
25 |
26 | /**
27 | * Unwrap [CompletionException] and return it's cause.
28 | * @throws NullCompletionCauseException if cause of [CompletionException] is null.
29 | * */
30 | fun Throwable.completionCause(): Throwable {
31 | return if (this is CompletionException) {
32 | this.cause ?: throw NullCompletionCauseException(this)
33 | } else {
34 | this
35 | }
36 | }
37 |
38 | private class NullCompletionCauseException(e: CompletionException) : Exception(e)
39 |
40 | fun CompletionStage.logException() {
41 | whenComplete { _, t ->
42 | if (t !== null) logger.error("Unhandled exception from CompletionStage", t)
43 | }
44 | }
45 |
46 | /**
47 | * Execute operation and close the [T].
48 | * */
49 | inline fun CompletableFuture.thenUse(crossinline operation: (T) -> R): CompletableFuture {
50 | return this.thenApply { response ->
51 | response.use(operation)
52 | }
53 | }
54 |
55 | /**
56 | * Perform [action], if it throws [ExecutionException] cause will be thrown instead.
57 | * */
58 | inline fun doUnwrappingExecutionException(action: () -> T): T {
59 | try {
60 | return action.invoke()
61 | } catch (e: ExecutionException) {
62 | throw e.cause ?: e
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/EmoticonCodeIdDbStorage.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.collect
6 | import kotlinx.coroutines.runBlocking
7 | import org.mapdb.DB
8 | import org.mapdb.HTreeMap
9 | import org.mapdb.Serializer
10 | import org.mapdb.serializer.GroupSerializer
11 |
12 | class EmoticonCodeIdDbStorage(
13 | db: DB,
14 | override val origin: Origin,
15 | private val caseSensitiveCode: Boolean
16 | ) : OriginEmoticonStorage {
17 |
18 | private val codeToId: HTreeMap
19 | private val idToEmoticon: HTreeMap
20 |
21 | init {
22 | codeToId = db
23 | .hashMap(origin.commonName + "-codeToId", Serializer.STRING, Serializer.STRING)
24 | .createOrOpen()
25 | idToEmoticon = db
26 | .hashMap(origin.commonName + "-idToEmoticon", Serializer.STRING, Serializer.JAVA as GroupSerializer)
27 | .createOrOpen()
28 | }
29 |
30 | override fun findByCode(code: String): Emoticon? {
31 | val cCode = if (caseSensitiveCode) code else code.toLowerCase()
32 | val id = codeToId.get(cCode) ?: return null
33 | return idToEmoticon.get(id)
34 | }
35 |
36 | override fun findById(id: String): Emoticon? {
37 | return idToEmoticon.get(id)
38 | }
39 |
40 | override fun getAll(): Collection {
41 | return idToEmoticon.values.filterNotNull()
42 | }
43 |
44 | override fun count(): Int {
45 | return idToEmoticon.size
46 | }
47 |
48 | override fun putAll(emoticons: Collection) {
49 | emoticons.forEach {
50 | putEmoticon(it)
51 | }
52 | }
53 |
54 | override fun putAll(emoticons: Flow) {
55 | runBlocking {
56 | emoticons.collect {
57 | putEmoticon(it)
58 | }
59 | }
60 | }
61 |
62 | private fun putEmoticon(emoticonAndId: EmoticonAndId) {
63 | val code = emoticonAndId.emoticon.code.let { c ->
64 | if (caseSensitiveCode) c else c.toLowerCase()
65 | }
66 |
67 | idToEmoticon.put(emoticonAndId.id, emoticonAndId.emoticon)
68 | codeToId.put(code, emoticonAndId.id)
69 | }
70 |
71 | override fun clear() {
72 | idToEmoticon.clear()
73 | codeToId.clear()
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/twitch/TokenAwareTwitchApiClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.twitch
2 |
3 | import failchat.chat.badge.ImageBadge
4 | import kotlinx.coroutines.sync.Mutex
5 | import kotlinx.coroutines.sync.withLock
6 |
7 | /**
8 | * The [TwitchApiClient] wrapper that:
9 | * - reuses existing token.
10 | * - retries the request if the token is expired.
11 | * */
12 | class TokenAwareTwitchApiClient(
13 | private val twitchApiClient: TwitchApiClient,
14 | private val clientSecret: String,
15 | private val tokenContainer: HelixTokenContainer
16 | ) {
17 |
18 | private val mutex = Mutex() // forbid parallel execution to prevent multiple tokens generation in the same time
19 |
20 | suspend fun getUserId(userName: String): Long {
21 | return mutex.withLock {
22 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
23 | twitchApiClient.getUserId(userName, it)
24 | }
25 | }
26 | }
27 |
28 | suspend fun getViewersCount(userName: String): Int {
29 | return mutex.withLock {
30 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
31 | twitchApiClient.getViewersCount(userName, it)
32 | }
33 | }
34 | }
35 |
36 | suspend fun getGlobalEmoticons(): List {
37 | return mutex.withLock {
38 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
39 | twitchApiClient.getGlobalEmoticons(it)
40 | }
41 | }
42 | }
43 |
44 | suspend fun getFirstLiveChannelName(): String {
45 | return mutex.withLock {
46 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
47 | twitchApiClient.getFirstLiveChannelName(it)
48 | }
49 | }
50 | }
51 |
52 | suspend fun getGlobalBadges(): Map {
53 | return mutex.withLock {
54 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
55 | twitchApiClient.getGlobalBadges(it)
56 | }
57 | }
58 | }
59 |
60 | suspend fun getChannelBadges(channelId: Long): Map {
61 | return mutex.withLock {
62 | doWithRetryOnAuthError(twitchApiClient, clientSecret, tokenContainer) {
63 | twitchApiClient.getChannelBadges(channelId, it)
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/github/GithubClient.kt:
--------------------------------------------------------------------------------
1 | package failchat.github
2 |
3 | import com.fasterxml.jackson.databind.JsonNode
4 | import com.fasterxml.jackson.databind.ObjectMapper
5 | import failchat.exception.UnexpectedResponseCodeException
6 | import failchat.exception.UnexpectedResponseException
7 | import failchat.util.thenUse
8 | import failchat.util.toFuture
9 | import mu.KotlinLogging
10 | import okhttp3.OkHttpClient
11 | import okhttp3.Request
12 | import java.util.concurrent.CompletableFuture
13 |
14 | class GithubClient(
15 | private val apiUrl: String,
16 | private val httpClient: OkHttpClient,
17 | private val objectMapper: ObjectMapper
18 | ) {
19 |
20 | private companion object {
21 | val logger = KotlinLogging.logger {}
22 | }
23 |
24 | fun requestLatestRelease(userName: String, repository: String): CompletableFuture {
25 | val request = Request.Builder()
26 | .url("${apiUrl.removeSuffix("/")}/repos/$userName/$repository/releases")
27 | .get()
28 | .build()
29 |
30 | return httpClient.newCall(request)
31 | .toFuture()
32 | .thenUse {
33 | if (it.code != 200) throw UnexpectedResponseCodeException(it.code)
34 | val responseBody = it.body ?: throw UnexpectedResponseException("null body")
35 | val releasesNode = objectMapper.readTree(responseBody.string())
36 | findLatestRelease(releasesNode) ?: throw NoReleasesFoundException()
37 | }
38 | }
39 |
40 | private fun findLatestRelease(releasesNode: JsonNode): Release? {
41 | return releasesNode.asSequence()
42 | .filter { !it.get("draft").asBoolean() }
43 | .filter { !it.get("prerelease").asBoolean() }
44 | .filter { !it.get("assets").isEmpty() }
45 | .map { parseRelease(it) }
46 | .filterNotNull()
47 | .firstOrNull()
48 | }
49 |
50 | private fun parseRelease(releaseNode: JsonNode): Release? {
51 | return try {
52 | Release(
53 | Version.parse(releaseNode.get("tag_name").asText()),
54 | releaseNode.get("html_url").asText(),
55 | releaseNode.get("assets").get(0).get("browser_download_url").asText()
56 | )
57 | } catch (e: Exception) {
58 | logger.warn("Failed to parse release node, skip it", e)
59 | null
60 | }
61 | }
62 |
63 | }
64 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/chat/handlers/IgnoreFilter.kt:
--------------------------------------------------------------------------------
1 | package failchat.chat.handlers
2 |
3 | import failchat.ConfigKeys
4 | import failchat.Origin
5 | import failchat.chat.Author
6 | import failchat.chat.ChatMessage
7 | import failchat.chat.MessageFilter
8 | import failchat.chatOrigins
9 | import failchat.util.value
10 | import mu.KotlinLogging
11 | import org.apache.commons.configuration2.Configuration
12 | import java.util.concurrent.atomic.AtomicReference
13 | import java.util.regex.Pattern
14 |
15 | /**
16 | * Фильтрует сообщения от пользователей в игнор-листе.
17 | * Баны хранятся в формате 'authorId#origin (optionalAuthorName)'.
18 | */
19 | class IgnoreFilter(private val config: Configuration) : MessageFilter {
20 |
21 | private companion object {
22 | val logger = KotlinLogging.logger {}
23 | }
24 |
25 | private val ignoreStringPattern: Pattern = compilePattern()
26 |
27 | private var ignoreSet: AtomicReference> = AtomicReference(emptySet())
28 |
29 | init {
30 | reloadConfig()
31 | }
32 |
33 | override fun filterMessage(message: ChatMessage): Boolean {
34 | val ignoreMessage = ignoreSet.value.asSequence()
35 | .filter { it.id == message.author.id && it.origin == message.author.origin }
36 | .any()
37 |
38 | if (ignoreMessage) logger.debug { "Message filtered by ignore filter: $message" }
39 | return ignoreMessage
40 | }
41 |
42 | fun reloadConfig() {
43 | ignoreSet.value = config.getStringArray(ConfigKeys.ignore).asSequence()
44 | .map { it to ignoreStringPattern.matcher(it) }
45 | .filter { (ignoreEntry, matcher) ->
46 | matcher.find().also { found ->
47 | if (!found) logger.warn("Ignore entry skipped: '{}'", ignoreEntry)
48 | }
49 | }
50 | .map { (_, matcher) ->
51 | val id = matcher.group("id")
52 | val name = matcher.group("name") ?: id
53 | Author(name, Origin.byCommonName(matcher.group("origin")), id)
54 | }
55 | .toSet()
56 | logger.debug("IgnoreFilter reloaded a config")
57 | }
58 |
59 | private fun compilePattern(): Pattern {
60 | val originsPattern = chatOrigins
61 | .map { it.commonName }
62 | .joinToString(separator = "|", prefix = "(", postfix = ")")
63 |
64 | return Pattern.compile("""(?.+)#(?$originsPattern)( \\((?.*)\\))?""")
65 | }
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/EmoticonManager.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import mu.KotlinLogging
4 |
5 | class EmoticonManager(
6 | private val storage: EmoticonStorage
7 | ) {
8 |
9 | private companion object {
10 | val logger = KotlinLogging.logger {}
11 | }
12 |
13 | /**
14 | * Load emoticons by the specified configurations and put them into the storage. Blocking call
15 | * Never throws [Exception].
16 | * */
17 | fun actualizeEmoticons(loadConfigurations: List>) {
18 | loadConfigurations.forEach {
19 | try {
20 | actualizeEmoticons(it)
21 | } catch (e: Exception) {
22 | logger.warn("Exception during loading emoticons for {}", it.origin, e)
23 | }
24 | }
25 | }
26 |
27 | /**
28 | * Load emoticons by specified configuration and put them into the storage. Blocking call
29 | * */
30 | private fun actualizeEmoticons(loadConfiguration: EmoticonLoadConfiguration) {
31 | val origin = loadConfiguration.origin
32 | val emoticonsInStorage = storage.getCount(origin)
33 |
34 | val loadResult = loadEmoticons(loadConfiguration)
35 |
36 | when (loadResult) {
37 | is LoadResult.Failure -> {
38 | logger.warn {"Failed to load emoticon list for $origin. Outdated list will be used, count: $emoticonsInStorage" }
39 | }
40 | is LoadResult.Success -> {
41 | logger.info { "Emoticon list loaded for $origin, count: ${loadResult.emoticonsLoaded}" }
42 | }
43 | }
44 | }
45 |
46 | private fun loadEmoticons(loadConfiguration: EmoticonLoadConfiguration): LoadResult {
47 | val origin = loadConfiguration.origin
48 |
49 | val emoticons = try {
50 | loadConfiguration.loader.loadEmoticons().join()
51 | } catch (e: Exception) {
52 | logger.warn(e) { "Failed to load emoticon list for $origin via bulk loader ${loadConfiguration.loader}" }
53 | return LoadResult.Failure
54 | }
55 |
56 | // Put data in storage
57 | val emoticonAndIdMapping = emoticons
58 | .map { EmoticonAndId(it, loadConfiguration.idExtractor.extractId(it)) }
59 | storage.clear(origin)
60 | storage.putMapping(origin, emoticonAndIdMapping)
61 |
62 | return LoadResult.Success(emoticons.size)
63 | }
64 |
65 | private sealed class LoadResult {
66 | class Success(val emoticonsLoaded: Int) : LoadResult()
67 | object Failure : LoadResult()
68 | }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/src/main/kotlin/failchat/emoticon/EmoticonCodeIdDbCompactStorage.kt:
--------------------------------------------------------------------------------
1 | package failchat.emoticon
2 |
3 | import failchat.Origin
4 | import kotlinx.coroutines.flow.Flow
5 | import kotlinx.coroutines.flow.collect
6 | import kotlinx.coroutines.runBlocking
7 | import org.mapdb.DB
8 | import org.mapdb.HTreeMap
9 | import org.mapdb.Serializer
10 | import java.io.Closeable
11 |
12 | /** Search by code is case insensitive. */
13 | class EmoticonCodeIdDbCompactStorage(
14 | db: DB,
15 | override val origin: Origin,
16 | private val emoticonFactory: EmoticonFactory
17 | ) : OriginEmoticonStorage, Closeable {
18 |
19 | private val lowerCaseCodeToId: HTreeMap
20 | private val idToNormalCaseCode: HTreeMap
21 |
22 | init {
23 | lowerCaseCodeToId = db
24 | .hashMap(origin.commonName + "-lowerCaseCodeToId", Serializer.STRING, Serializer.STRING)
25 | .createOrOpen()
26 | idToNormalCaseCode = db
27 | .hashMap(origin.commonName + "-idToNormalCaseCode", Serializer.STRING, Serializer.STRING)
28 | .createOrOpen()
29 | }
30 |
31 | override fun findByCode(code: String): Emoticon? {
32 | val id = lowerCaseCodeToId.get(code.lowercase()) ?: return null
33 | val normalCaseCode = idToNormalCaseCode.get(id) ?: return null
34 | return emoticonFactory.create(id, normalCaseCode)
35 | }
36 |
37 | override fun findById(id: String): Emoticon? {
38 | val normalCaseCode = idToNormalCaseCode.get(id) ?: return null
39 | return emoticonFactory.create(id, normalCaseCode)
40 | }
41 |
42 | override fun getAll(): Collection {
43 | throw NotImplementedError()
44 | }
45 |
46 | override fun count(): Int {
47 | return idToNormalCaseCode.size
48 | }
49 |
50 | override fun putAll(emoticons: Collection) {
51 | emoticons.forEach {
52 | putEmoticon(it)
53 | }
54 | }
55 |
56 | override fun putAll(emoticons: Flow) {
57 | runBlocking {
58 | emoticons.collect {
59 | putEmoticon(it)
60 | }
61 | }
62 | }
63 |
64 | private fun putEmoticon(emoticonAndId: EmoticonAndId) {
65 | idToNormalCaseCode.put(emoticonAndId.id, emoticonAndId.emoticon.code)
66 | lowerCaseCodeToId.putIfAbsent(emoticonAndId.emoticon.code.lowercase(), emoticonAndId.id)
67 | }
68 |
69 | override fun clear() {
70 | idToNormalCaseCode.clear()
71 | lowerCaseCodeToId.clear()
72 | }
73 |
74 | override fun close() {
75 | lowerCaseCodeToId.close()
76 | idToNormalCaseCode.close()
77 | }
78 | }
79 |
--------------------------------------------------------------------------------