Skip to content

Example implementation code

techygrrrl edited this page Jul 8, 2024 · 2 revisions

This page will contain example code for parsing IRC chat commands into queue commands and supporting these queue commands.

Kotlin

Data types

These are the data types I'll be using:

IrcPrivateMessage

The data from Twitch deserialized into a data class. Most properties aren't going to be useful for this, just message which is the chat message, and user which is the user sending the message.

@Serializable
data class IrcPrivateMessage(
    @SerialName("raw") val raw: String,
    @SerialName("raw_type") val rawType: String,
    @SerialName("message") val message: String,
    @SerialName("tags") val tags: Map<String, String>,
    @SerialName("emotes") val emotes: List<IrcDataEmote>?,
    @SerialName("bits") val bits: Int = 0,
    @SerialName("first_message") val firstMessage: Boolean,
    @SerialName("user") val user: IrcUser,
)

ChatCommand

sealed class ChatCommand() {
    abstract val name: String


    // region Queuing

    class QueueClear(val userId: String): ChatCommand() {
        override val name = "clear"
    }

    class QueueInfo(): ChatCommand() {
        override val name = "info"
    }

    class QueueJoin(
        val username: String,
        val userId: String,
        val notes: String,
    ): ChatCommand() {
        override val name = "join"
    }

    class QueueLeave(val userId: String): ChatCommand() {
        override val name = "leave"
    }

    class QueueNext(val userId: String): ChatCommand() {
        override val name = "next"
    }

    class QueuePosition(
        val username: String,
        val userId: String
    ): ChatCommand() {
        override val name = "position"
    }

    class QueueUnknown(val userId: String, val username: String): ChatCommand() {
        override val name = "unknown"
    }

    // endregion Queuing
}

Networking utility classes

These classes will be helpful for making API calls.

The following libraries are used:

plugins {
    id("org.jetbrains.kotlin.plugin.serialization") version "1.8.20"
}

dependencies {
    // Ktor HTTP client
    implementation("io.ktor:ktor-client-core:2.3.0")
    implementation("io.ktor:ktor-client-cio:2.3.0")

    // JSON (de)serialization
    implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.0")
}
class QueuerrrApiClient(
    private val apiToken: String,
    val baseUrl: String,
) {
    val httpClient = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                ignoreUnknownKeys = true
            })
        }
    }

    fun authHeaders(): Map<String, String> = mapOf(
        "Authorization" to "Bearer $apiToken",
    )

    suspend inline fun <reified ResType>makeGet(
        path: String
    ): Result<ResType> {
        val url = baseUrl + path
        return httpClient.makeGet<ResType>(url, authHeaders())
    }
}

// region Requests and Responses

@Serializable
data class QueuerrrResponseStatus(
    val status: String,
)

@Serializable
data class QueuerrrResponsePosition(
    val position: Int,
)

@Serializable
data class QueuerrrUser(
    @SerialName("created_at") val createdAt: String,
    @SerialName("twitch_username") val username: String,
    @SerialName("twitch_user_id") val userId: String,
    @SerialName("notes") val notes: String,
)

@Serializable
data class QueuerrrResponseInfo(
    val total: Int,
    val users: List<QueuerrrUser>,
)

// endregion Requests and Responses

HTTP client helper:

suspend inline fun <reified T> HttpClient.makeGet(
    url: String,
    headersMap: Map<String, String>? = null,
): Result<T> =
    try {
        val response: HttpResponse = get(url) {
            contentType(ContentType.Application.Json)
            headers {
                headersMap?.forEach { (k, v) -> append(k, v) }
            }
        }

        if (response.status.isSuccess()) {
            val responseBody: T = response.body()
            Result.success(responseBody)
        } else {
            val responseText = response.bodyAsText()
            Result.failure(Throwable(responseText))
        }
    } catch (e: Exception) {
        Result.failure(e)
    }

Example initialization of the API client:

val queuerrrApiClient = QueuerrrApiClient(
    apiToken = appConfig.queuerrrApiToken, // from environment variables
    baseUrl = "https://queuerrr.vercel.app/api/"
)

Parsing the command from the message string

Here's how I'm parsing the commands in Kotlin. There's probably room for improvement, e.g. sending the username and userId with every command in case it's needed later.

fun parseCommand(ircPrivateMessage: IrcPrivateMessage): ChatCommand? {
    val message = ircPrivateMessage.message
    val segments = message.split("\\b".toRegex())
    if (segments.size < 2 || segments[0] != "!") {
        // Not a command
        return null
    }

    // Look for custom commands
    val commandName = segments[1]

    if (commandName == "queue") {
        val verb = tryOrNull { message.words[1] }

        when (verb) {
            "clear" -> return ChatCommand.QueueClear(ircPrivateMessage.user.id)
            "info" -> return ChatCommand.QueueInfo()
            "join" -> {
                val notes = message.replace("!queue join", "").trim()
                return ChatCommand.QueueJoin(
                    username = ircPrivateMessage.user.username,
                    userId = ircPrivateMessage.user.id,
                    notes = notes
                )
            }
            "leave" -> return ChatCommand.QueueLeave(ircPrivateMessage.user.id)
            "next" -> return ChatCommand.QueueNext(ircPrivateMessage.user.id)
            "position" -> {
                return ChatCommand.QueuePosition(
                    username = ircPrivateMessage.user.username,
                    userId = ircPrivateMessage.user.id,
                )
            }
            else -> {
                return ChatCommand.QueueUnknown(
                    userId = ircPrivateMessage.user.id,
                    username = ircPrivateMessage.user.username,
                )
            }
        }
    }

    if (commandName == "join") {
        val notes = message.replace("!join", "").trim()
        return ChatCommand.QueueJoin(
            username = ircPrivateMessage.user.username,
            userId = ircPrivateMessage.user.id,
            notes = notes
        )
    }

    if (commandName == "leave") {
        return ChatCommand.QueueLeave(ircPrivateMessage.user.id)
    }

    if (commandName == "next") {
        return ChatCommand.QueueNext(ircPrivateMessage.user.id)
    }

    if (commandName == "position") {
        return ChatCommand.QueuePosition(
            username = ircPrivateMessage.user.username,
            userId = ircPrivateMessage.user.id,
        )
    }

    return null
}

Handling the queue command

After the command has been parsed into a sealed class with the relevant data, you can then handle the command by making API calls:

private suspend fun handleQueueCommands(parsedCommand: ChatCommand) {
    when (parsedCommand) {
        is ChatCommand.QueueClear -> {
            if (parsedCommand.userId != userIds.streamer) return

            queuerrrApiClient.makeGet<QueuerrrResponseStatus>("clear")
                .onSuccess { response ->
                    sendChat(response.status)
                }
                .onFailure { error ->
                    logger.error("QueueClear error: $error")
                }
        }
        is ChatCommand.QueueInfo -> {
            queuerrrApiClient.makeGet<QueuerrrResponseInfo>("info")
                .onSuccess { response ->
                    val message = if (response.users.isEmpty()) {
                        "There are currently no users in the queue. You can join the queue with !join"
                    } else {
                        val usersInQueue = response.users.joinToString(", ") { it.username }
                        "Users currently in queue: $usersInQueue. You can view the queue here: https://queuerrr.vercel.app"
                    }
                    sendChat(message)
                }
                .onFailure { error ->
                    sendChat("You can view the queue here: https://queuerrr.vercel.app")
                    logger.error("QueueInfo error: $error")
                }
        }
        is ChatCommand.QueueJoin -> {
            val encodedNotes = tryOrNull { URLEncoder.encode(parsedCommand.notes, "utf-8") }
            queuerrrApiClient.makeGet<QueuerrrUser>("join?user_id=${parsedCommand.userId}&username=${parsedCommand.username}&notes=$encodedNotes")
                .onSuccess { response ->
                    sendChat("@${response.username} you've been successfully added to the queue!")
                }
                .onFailure { error ->
                    sendChat("BibleThump failed to add ${parsedCommand.username} to the queue")
                    logger.error("QueueJoin error: $error")
                }
        }
        is ChatCommand.QueueLeave -> {
            queuerrrApiClient.makeGet<QueuerrrResponseStatus>("leave?user_id=${parsedCommand.userId}")
                .onSuccess { response ->
                    sendChat(response.status)
                }
                .onFailure { error ->
                    logger.error("QueueLeave error: $error")
                    sendChat("user not in queue")
                }
        }
        is ChatCommand.QueueNext -> {
            if (parsedCommand.userId != userIds.streamer) {
                sendChat("WutFace only techygrrrl can use this command")
                return
            }

            queuerrrApiClient.makeGet<QueuerrrUser>("next")
                .onSuccess { response ->
                    sendChat("@${response.username} it's your turn now!")
                }
                .onFailure { error ->
                    logger.error("QueueNext error: $error")
                    sendChat("BibleThump queue is empty")
                }
        }
        is ChatCommand.QueuePosition -> {
            queuerrrApiClient.makeGet<QueuerrrResponsePosition>("position?user_id=${parsedCommand.userId}")
                .onSuccess { response ->
                    if (response.position == -1) {
                        sendChat("@${parsedCommand.username} you're not in the queue! use command !join to join the queue")
                    } else {
                        sendChat("@${parsedCommand.username} you're in position ${response.position}!")
                    }
                }
                .onFailure { error ->
                    logger.error("QueuePosition error: $error")
                    sendChat("BibleThump error getting queue position")
                }
        }
        is ChatCommand.QueueUnknown -> {
            val commands = listOf("!join", "!leave", "!position")
            val message = "@${parsedCommand.username} HeyGuys Missing or invalid command verb. Valid commands are: ${commands.joinToString(" ")}. You can also view the queue here: https://queuerrr.vercel.app"

            sendChat(message)
        }
        else -> {}
    }
}