Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: KTX module #516

Merged
merged 15 commits into from May 22, 2022
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -40,6 +40,7 @@ Project
* Chat
* PubSub
* GraphQL
* Kotlin KTX
iProdigy marked this conversation as resolved.
Show resolved Hide resolved

## Problems

Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Expand Up @@ -101,6 +101,10 @@ subprojects {
// Credential Manager
api(group = "com.github.philippheuer.credentialmanager", name = "credentialmanager", version = "0.1.2")

// Kotlin libraries
api(group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version = "1.6.0")
testImplementation(group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version = "1.6.0")
iProdigy marked this conversation as resolved.
Show resolved Hide resolved

// HTTP Client
api(group = "io.github.openfeign", name = "feign-slf4j", version = "11.8")
api(group = "io.github.openfeign", name = "feign-okhttp", version = "11.8")
Expand Down
4 changes: 3 additions & 1 deletion settings.gradle.kts
Expand Up @@ -11,7 +11,8 @@ include(
":rest-tmi",
":pubsub",
":graphql",
":twitch4j"
":twitch4j",
":twitch4j-ktx"
)

project(":common").name = "twitch4j-common"
Expand All @@ -25,3 +26,4 @@ project(":rest-tmi").name = "twitch4j-messaginginterface"
project(":pubsub").name = "twitch4j-pubsub"
project(":graphql").name = "twitch4j-graphql"
project(":twitch4j").name = "twitch4j"
project(":twitch4j-ktx").name = "twitch4j-ktx"
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
29 changes: 29 additions & 0 deletions twitch4j-ktx/build.gradle.kts
@@ -0,0 +1,29 @@
// In this section you declare the dependencies for your production and test code
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
dependencies {
// Twitch4J Modules
// We use compileOnly so using this library does not require all modules.
// This does mean that using code that references modules not available will crash.
// This shouldn't be an issue as most, if not all, code are extension functions.
compileOnly(project(":twitch4j"))

// Kotlin coroutines
api(group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core")

This comment was marked as resolved.

Copy link
Contributor Author

@SubSide SubSide Jan 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using it, so there is no reason to include it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so there is no reason to set kotlinOptions.jvmTarget in KotlinCompile tasks

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't need to explicitly add stdlib: https://kotlinlang.org/docs/gradle.html#dependency-on-the-standard-library

we could specify jvmTarget (but it already defaults to 1.8): https://kotlinlang.org/docs/gradle.html#attributes-specific-to-jvm

testImplementation(group = "org.jetbrains.kotlinx", name="kotlinx-coroutines-test")
testImplementation(project(":twitch4j"))
}

tasks.javadoc {
options {
title = "Twitch4J (v${version}) - Kotlin extension functions"
windowTitle = "Twitch4J (v${version}) - Kotlin extension functions"
}
}
PhilippHeuer marked this conversation as resolved.
Show resolved Hide resolved

publishing.publications.withType<MavenPublication> {
pom {
name.set("Twitch4J API - Kotlin extension functions")
description.set("Twitch4J Kotlin extension functions")
}
}


77 changes: 77 additions & 0 deletions twitch4j-ktx/src/main/java/com/github/twitch4j/ktx/chat/ChatKtx.kt
@@ -0,0 +1,77 @@
package com.github.twitch4j.ktx.chat

import com.github.twitch4j.chat.TwitchChat
import com.github.twitch4j.chat.events.AbstractChannelEvent
import com.github.twitch4j.chat.events.channel.ChannelMessageEvent
import com.github.twitch4j.ktx.main.flowOn
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

/**
* Creates an events flow for the given channel.
*
* Disclaimer: The autoJoinAndLeave feature will automatically leave the twitch channel when you remove the collector of
* this flow. If this is a problem for you (e.g. you need to stay joined in the channel after removing a collector)
* you are better off setting autoJoinAndLeave to false, and join/leave the channel on your own.
*
* @param channel The channel to retrieve events from
* @param autoJoinAndLeave Whether we automatically join and leave the channel or not
* @return A flow object that encapsulates handling joining, listening to, and leaving a channel
*/
inline fun <reified T : AbstractChannelEvent> TwitchChat.channelEventsAsFlow(
channel: String,
autoJoinAndLeave: Boolean = true
): Flow<T> = channelEventsAsFlow(T::class.java, channel, autoJoinAndLeave)

/**
* Creates a chat message flow for the given channel.
*
* Disclaimer: The autoJoinAndLeave feature will automatically leave the twitch channel when you remove the collector of
* this flow. If this is a problem for you (e.g. you need to stay joined in the channel after removing a collector)
* you are better off setting autoJoinAndLeave to false, and join/leave the channel on your own.
*
* @param channel The channel to read chat messages from
* @param autoJoinAndLeave Whether we automatically join and leave the channel or not
* @return A flow object that encapsulates joining, listening to messages, and leaving a channel
*/
fun TwitchChat.channelChatAsFlow(
channel: String,
autoJoinAndLeave: Boolean = true
): Flow<ChannelMessageEvent> = channelEventsAsFlow(ChannelMessageEvent::class.java, channel, autoJoinAndLeave)
iProdigy marked this conversation as resolved.
Show resolved Hide resolved


/**
* Creates an events flow for the given channel.
*
* Disclaimer: The autoJoinAndLeave feature will automatically leave the twitch channel when you remove the collector of
* this flow. If this is a problem for you (e.g. you need to stay joined in the channel after removing a collector)
* you are better off setting autoJoinAndLeave to false, and join/leave the channel on your own.
*
* @param klass The event class to receive
* @param channel The channel to retrieve events from
* @param autoJoinAndLeave Whether we automatically join and leave the channel or not
* @return A flow object that encapsulates handling joining, listening to, and leaving a channel
*/
fun <T : AbstractChannelEvent> TwitchChat.channelEventsAsFlow(
iProdigy marked this conversation as resolved.
Show resolved Hide resolved
klass: Class<T>,
channel: String,
autoJoinAndLeave: Boolean
): Flow<T> = channelFlow {
if (autoJoinAndLeave) {
connect()
joinChannel(channel)
}

launch {
eventManager.flowOn(klass)
.filter { it.channel.name.equals(channel, true) }
.collect(::send)
}

awaitClose {
if (autoJoinAndLeave) {
leaveChannel(channel)
}
}
}
@@ -0,0 +1,33 @@
package com.github.twitch4j.ktx.main
PhilippHeuer marked this conversation as resolved.
Show resolved Hide resolved

import com.github.philippheuer.events4j.core.EventManager
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow

/**
* Creates a flow for a specific event.
*
* Automatically disposes of the subscription when there are no more collectors.
*
* @return A Flow object that encapsulates the eventListener and handles the subscription
*/
inline fun <reified T> EventManager.flowOn(): Flow<T> = flowOn(T::class.java)

/**
* Creates a flow for a specific event.
*
* Automatically disposes of the subscription when there are no more collectors.
*
* @param klass The class to receive events for
* @return A Flow object that encapsulates the eventListener and handles the subscription
*/
fun <T> EventManager.flowOn(
klass: Class<T>
): Flow<T> = callbackFlow {
val subscription = onEvent(klass, ::trySend)

awaitClose {
subscription.dispose()
}
}
@@ -0,0 +1,19 @@
package com.github.twitch4j.ktx.main

import com.netflix.hystrix.HystrixCommand
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
* A simple wrapper function that will default to dispatch the execute job in the IO dispatcher.
* Also see {@link HystrixCommand#execute}
*
* @param dispatcher The dispatcher to run in
* @return The result of execute
*/
suspend fun <T> HystrixCommand<T>.get(
dispatcher: CoroutineDispatcher = Dispatchers.IO
): T = withContext(dispatcher) {
execute()
}
184 changes: 184 additions & 0 deletions twitch4j-ktx/src/test/java/com/github/twitch4j/ktx/chat/ChatKtxTest.kt
@@ -0,0 +1,184 @@
package com.github.twitch4j.ktx.chat

import com.github.twitch4j.chat.events.AbstractChannelEvent
import com.github.twitch4j.chat.events.channel.ChannelJoinEvent
import com.github.twitch4j.chat.events.channel.ChannelMessageEvent
import com.github.twitch4j.chat.events.channel.IRCMessageEvent
import com.github.twitch4j.common.events.domain.EventChannel
import com.github.twitch4j.common.events.domain.EventUser
import com.github.twitch4j.ktx.mock.MockChat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import lombok.extern.slf4j.Slf4j
import org.junit.jupiter.api.*
import org.junit.jupiter.api.Assertions.*


@ExperimentalCoroutinesApi
@Slf4j
@Tag("unittest")
class ChatKtxTest {

@Test
fun `Assert that channels are not the same`() {
assertNotEquals(testChannel1.id.lowercase(), testChannel2.id.lowercase())
assertNotEquals(testChannel1.name.lowercase(), testChannel2.name.lowercase())
}

@Test
fun `Check if we only receive events from the current channel`() = runTest {
val mockTwitchChat = MockChat()

var eventsReceived = 0

val collectorJob = launch {
mockTwitchChat.channelEventsAsFlow<AbstractChannelEvent>(testChannelName1, false)
.collect {
assertEquals(it.channel.name, testChannelName1, "Incorrect channel event received")
eventsReceived++
}
}
runCurrent()

launch {
mockTwitchChat.eventManager.apply {
publish(testChannelJoinEvent1)
publish(testChannelJoinEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
}
}
runCurrent()

collectorJob.cancel()

assertEquals(4, eventsReceived, "Expected the amount of events received to be exactly 4")
}

@Test
fun `Check if we only receive message events`() = runTest {
val mockTwitchChat = MockChat()

var eventsReceived = 0

val collectorJob = launch {
mockTwitchChat.channelChatAsFlow(testChannelName1)
.collect {
eventsReceived++
}
}
runCurrent()

launch {
mockTwitchChat.eventManager.apply {
publish(testChannelJoinEvent1)
publish(testChannelJoinEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
}
}
runCurrent()

collectorJob.cancel()

assertEquals(3, eventsReceived, "Expected the amount of events received to be exactly 3")
}

@Test
fun `Check if we only receive the events we ask for`() = runTest {
val mockTwitchChat = MockChat()

var eventsReceived = 0

val collectorJob = launch {
mockTwitchChat.channelEventsAsFlow<ChannelJoinEvent>(testChannelName1)
.collect {
eventsReceived++
}
}
runCurrent()

launch {
mockTwitchChat.eventManager.apply {
publish(testChannelJoinEvent1)
publish(testChannelJoinEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
publish(testChannelMessageEvent1)
publish(testChannelMessageEvent2)
}
}
runCurrent()

collectorJob.cancel()

assertEquals(1, eventsReceived, "Expected the amount of events received to be exactly 3")
}

@Test
fun `Check if we do not join the channel automatically when we pass false to channelFlow`() = runTest {
val mockTwitchChat = MockChat().apply { disconnect() }

val collectorJob = launch {
mockTwitchChat.channelEventsAsFlow<AbstractChannelEvent>(testChannelName1, false)
.collect { /* NO-OP */ }
}
runCurrent()

assertEquals(false, mockTwitchChat.isConnected, "Automatically connected, it should not do that!")
assertEquals(0, mockTwitchChat.channels.size, "Did join the channel, although we told it not to")

collectorJob.cancel()
}

@Test
fun `Check if we automatically connect, join and leave`() = runTest {
val mockTwitchChat = MockChat().apply { disconnect() }

val collectorJob = launch {
mockTwitchChat.channelEventsAsFlow<AbstractChannelEvent>(testChannelName1, true)
.collect { /* NO-OP */ }
}
runCurrent()

assertEquals(true, mockTwitchChat.isConnected, "Did not set connected to true")
assertEquals(1, mockTwitchChat.channels.size, "Did not join any channel")
assertEquals(
testChannelName1.lowercase(),
mockTwitchChat.channels.firstOrNull(),
"Did not connect to the correct channel"
)

collectorJob.cancel()
runCurrent()

assertEquals(0, mockTwitchChat.channels.size, "Did not leave the channel after cancelling the flow")
}

private val testChannelId1 = "channelTestId1"
private val testChannelId2 = "channelTestId2"
private val testChannelName1 = "channelTestName1"
private val testChannelName2 = "channelTestName2"

private val testChannel1 = EventChannel(testChannelId1, testChannelName1)
private val testChannel2 = EventChannel(testChannelId2, testChannelName2)
private val testUser = EventUser("testUserId", "testUserName")

private val testMessage = "TestMessage"
private val testIrcMessageEvent = IRCMessageEvent(testMessage, emptyMap(), emptyMap(), mutableListOf())
private val testChannelJoinEvent1 = ChannelJoinEvent(testChannel1, testUser)
private val testChannelJoinEvent2 = ChannelJoinEvent(testChannel2, testUser)
private val testChannelMessageEvent1 =
ChannelMessageEvent(testChannel1, testIrcMessageEvent, testUser, testMessage, emptySet())
private val testChannelMessageEvent2 =
ChannelMessageEvent(testChannel2, testIrcMessageEvent, testUser, testMessage, emptySet())
}