Compare commits

..
14 Commits
18 changed files with 312 additions and 169 deletions
+2
View File
@@ -8,3 +8,5 @@ ktlint_standard_multiline-loop = disabled
ktlint_function_signature_body_expression_wrapping = default ktlint_function_signature_body_expression_wrapping = default
ktlint_standard_property-naming = disabled ktlint_standard_property-naming = disabled
ktlint_standard_function-signature = disabled ktlint_standard_function-signature = disabled
ktlint_standard_class-signature = disabled
ktlint_standard_multiline-if-else = disabled
+1
View File
@@ -47,3 +47,4 @@ bin/
src/test/resources/logs/*.log src/test/resources/logs/*.log
!src/test/resources/logs/.gitkeep !src/test/resources/logs/.gitkeep
.idea .idea
todo.md
@@ -1,5 +1,6 @@
package com.shr4pnel.ferretirc package com.shr4pnel.ferretirc
import com.shr4pnel.ferretirc.irc.IRCChannel
import com.shr4pnel.ferretirc.irc.Server import com.shr4pnel.ferretirc.irc.Server
import com.shr4pnel.ferretirc.irc.User import com.shr4pnel.ferretirc.irc.User
import com.shr4pnel.ferretirc.net.Connection import com.shr4pnel.ferretirc.net.Connection
@@ -17,13 +18,13 @@ import org.slf4j.helpers.NOPLogger
class IrcClient( class IrcClient(
hostname: String, hostname: String,
port: Int, port: Int,
var enableLogging: Boolean = false, val enableLogging: Boolean = false,
) { ) {
init { init {
KotlinLoggingConfiguration.logStartupMessage = false KotlinLoggingConfiguration.logStartupMessage = false
} }
private val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER) // private val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
lateinit var server: Server lateinit var server: Server
@@ -43,14 +44,15 @@ class IrcClient(
} }
/** /**
* Send a message to the IRC server writer * Send a message to the IRC server message writer
* This is asynchronous and ordered
* @param message The client message to send to the server * @param message The client message to send to the server
*/ */
suspend fun queueMessage(message: ClientMessage) { suspend fun queueMessage(message: ClientMessage) {
connection.writer.outgoingMessages.send(message) connection.writer.outgoingMessages.send(message)
} }
suspend fun join(channel: IRCChannel) = queueMessage(ClientMessage.Join(channel))
/** /**
* Send a message to the IRC server writer * Send a message to the IRC server writer
* This is asynchronous and ordered * This is asynchronous and ordered
@@ -85,7 +87,7 @@ class IrcClient(
* @param predicate A function to filter a message based on its parameters * @param predicate A function to filter a message based on its parameters
* @see com.shr4pnel.ferretirc.IrcClient.waitForNext * @see com.shr4pnel.ferretirc.IrcClient.waitForNext
*/ */
suspend inline fun <reified T : ServerMessage> queueAndWaitForNext( suspend inline fun <reified T : ServerMessage> queueMessageAndWaitForNext(
message: ClientMessage, message: ClientMessage,
noinline predicate: (T) -> Boolean = { true }, noinline predicate: (T) -> Boolean = { true },
): T = server.waitForNext(T::class, predicate) { queueMessage(message) } ): T = server.waitForNext(T::class, predicate) { queueMessage(message) }
@@ -96,10 +98,10 @@ class IrcClient(
* @param action A function to execute after message buffer flow collection has begun * @param action A function to execute after message buffer flow collection has begun
* @see com.shr4pnel.ferretirc.IrcClient.waitForNext * @see com.shr4pnel.ferretirc.IrcClient.waitForNext
*/ */
suspend inline fun <reified T : ServerMessage> waitForNextAfterAction( suspend inline fun <reified T : ServerMessage> waitForNextMessageAfterAction(
noinline predicate: (T) -> Boolean = { true }, noinline predicate: (T) -> Boolean = { true },
noinline action: suspend () -> Unit, noinline action: suspend () -> Unit,
): T = server.waitForNext(T::class, predicate, action) ): T = server.waitForNext(T::class, predicate) { action() }
suspend fun register( suspend fun register(
nick: String, nick: String,
@@ -119,6 +121,6 @@ class IrcClient(
} }
add(ClientMessage.Cap.END()) add(ClientMessage.Cap.END())
}.toTypedArray() }.toTypedArray()
queueMessages(*messages) waitForNextMessageAfterAction<ServerMessage.Numeric.RPL_WELCOME> { queueMessages(*messages) }
} }
} }
@@ -1,3 +1,3 @@
package com.shr4pnel.ferretirc.irc package com.shr4pnel.ferretirc.irc
class IRCChannel(val chanName: String, val clientCount: Int, val topic: String) data class IRCChannel(val chanName: String, val clientCount: Int, val topic: String)
@@ -1,23 +1,24 @@
package com.shr4pnel.ferretirc.irc package com.shr4pnel.ferretirc.irc
data class ISupportFeatures(val capabilities: List<Feature>) sealed class ISupportFeature(key: String) {
sealed class Feature(key: String) {
// Indicates the maximum number of online nicknames a user may have in their accept list. First introduced in ircd-ratbox-3.0.9(r28737). // Indicates the maximum number of online nicknames a user may have in their accept list. First introduced in ircd-ratbox-3.0.9(r28737).
data class ACCEPT(val max: Int) : Feature("ACCEPT") data class ACCEPT(val max: Int) : ISupportFeature("ACCEPT")
// Indicates the maximum length of an away message. If "number" is not defined, there is no limit. // Indicates the maximum length of an away message. If "number" is not defined, there is no limit.
data class AWAYLEN(val max: Int) : Feature("AWAYLEN") data class AWAYLEN(val max: Int) : ISupportFeature("AWAYLEN")
// Indicates the character to be used as a user mode to let clients mark themselves as bots by setting it // Indicates the character to be used as a user mode to let clients mark themselves as bots by setting it
data class BOT(val letter: Char) : Feature("BOT") data class BOT(val letter: Char) : ISupportFeature("BOT")
// Indicates that the "caller-id" user mode is supported, which rejects messages from unauthorized users. "letter" defines the mode character, which is used for this feature. If the value is not given, it defaults to the mode "g". // Indicates that the "caller-id" user mode is supported, which rejects messages from unauthorized users. "letter" defines the mode character, which is used for this feature. If the value is not given, it defaults to the mode "g".
data class CALLERID(val letter: Char) : Feature("CALLERID") data class CALLERID(val letter: Char) : ISupportFeature("CALLERID")
// Indicates the method that’s used to compare equality of case-insensitive strings (such as nick/channel names). Typical values include "ascii" and "rfc1459". "rfc3454" is a proposed value that refers to the stringprep method described in RFC3454 (typically used for UTF-8 casefolding). // Indicates the method that’s used to compare equality of case-insensitive strings (such as nick/channel names). Typical values include "ascii" and "rfc1459". "rfc3454" is a proposed value that refers to the stringprep method described in RFC3454 (typically used for UTF-8 casefolding).
data class CASEMAPPING(val method: String) : Feature("CASEMAPPING") data class CASEMAPPING(val method: String) : ISupportFeature("CASEMAPPING")
// Indicates the maximum length of a nickname that a client may use. Other clients on the network may have nicknames longer than this. /** Indicates the maximum length of a nickname that a client may use. Other clients on the network may have nicknames longer than this. */
data class MAXNICKLEN(val max: Int) : Feature("MAXNICKLEN") data class MAXNICKLEN(val max: Int) : ISupportFeature("MAXNICKLEN")
/** deprecated but still commonly sent by IRCDs */
data class CHARSET(val charset: String) : ISupportFeature("CHARSET")
} }
@@ -2,46 +2,131 @@ package com.shr4pnel.ferretirc.irc
import com.shr4pnel.ferretirc.net.messages.ClientMessage import com.shr4pnel.ferretirc.net.messages.ClientMessage
import com.shr4pnel.ferretirc.net.messages.ServerMessage import com.shr4pnel.ferretirc.net.messages.ServerMessage
import com.shr4pnel.ferretirc.net.messages.ServerMessage.Numeric
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger import io.github.oshai.kotlinlogging.slf4j.logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.slf4j.helpers.NOPLogger import org.slf4j.helpers.NOPLogger
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.cast import kotlin.reflect.cast
class Server( class Server(
private val msgBuffer: SharedFlow<ServerMessage>, @PublishedApi internal val msgBuffer: SharedFlow<ServerMessage>,
private val outgoingMessages: Channel<ClientMessage>, @PublishedApi internal val outgoingMessages: Channel<ClientMessage>,
private val scope: CoroutineScope,
enableLogging: Boolean = false, enableLogging: Boolean = false,
) { ) {
private val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER) @PublishedApi
internal val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
lateinit var features: ISupportFeatures private val _features = MutableStateFlow<List<ISupportFeature>>(emptyList())
private set val features: StateFlow<List<ISupportFeature>> = _features.asStateFlow()
var channels: Set<IRCChannel> = setOf() /** Backing field for channels storing the current state of visible channels */
private set private val _channels = MutableStateFlow<List<IRCChannel>>(emptyList())
val channels: StateFlow<List<IRCChannel>> = _channels.asStateFlow()
val users = mutableListOf<User>() private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
suspend fun fetchChannels(): Set<IRCChannel> { init {
listen()
}
private companion object {
fun parseISupportFeature(msg: Numeric.RPL_ISUPPORT): List<ISupportFeature> {
val mutableFeatures = mutableListOf<ISupportFeature>()
msg.keypairs.forEach {
if (it.contains("=")) {
val split = it.split("=")
val key = split.first().uppercase()
val value = split.last()
val feature = when (key) {
"ACCEPT" -> ISupportFeature.ACCEPT(value.toInt())
"AWAYLEN" -> ISupportFeature.AWAYLEN(value.toInt())
"BOT" -> ISupportFeature.BOT(value.single())
"CALLERID" -> ISupportFeature.CALLERID(value.single())
"CASEMAPPING" -> ISupportFeature.CASEMAPPING(value)
"MAXNICKLEN" -> ISupportFeature.MAXNICKLEN(value.toInt())
"CHARSET" -> ISupportFeature.CHARSET(value)
else -> null
}
if (feature != null)
mutableFeatures.add(feature)
}
}
return mutableFeatures.toList()
}
}
suspend fun fetchChannels(timeoutMillis: Long = 5000): List<IRCChannel> {
logger.debug { "Fetching IRC channels" } logger.debug { "Fetching IRC channels" }
val buffer = mutableSetOf<IRCChannel>() val buffer = mutableListOf<IRCChannel>()
msgBuffer msgBuffer
.onSubscription { outgoingMessages.send(ClientMessage.List()) } .onSubscription { outgoingMessages.send(ClientMessage.List()) }
.filterIsInstance<ServerMessage.Numeric>() .filterIsInstance<Numeric>()
.takeWhile { it !is ServerMessage.Numeric.RPL_LISTEND } .takeWhile { it !is Numeric.RPL_LISTEND }
.filterIsInstance<ServerMessage.Numeric.RPL_LIST>() .filterIsInstance<Numeric.RPL_LIST>()
.collect { buffer.add(IRCChannel(it.chanName, it.clientCount, it.topic)) } .collect { buffer.add(IRCChannel(it.chanName, it.clientCount, it.topic)) }
channels = buffer.toSortedSet(compareBy { it.chanName }) val sortedChannels = buffer.sortedBy { it.chanName }
return channels _channels.value = sortedChannels
return sortedChannels
}
private fun listen() = scope.launch {
msgBuffer.collect { msg ->
when (msg) {
is Numeric.RPL_ISUPPORT -> {
val feats = parseISupportFeature(msg)
_features.value += feats
feats.forEach { logger.debug { "Parsed ISUPPORT: $it" } }
}
is ServerMessage.Ping -> {
val pong = ClientMessage.Pong(msg.token)
logger.debug { "[C->S]: Responding to server $msg with $pong" }
scope.launch { outgoingMessages.send(pong) }
}
else -> {}
}
}
}
/**
* Fetch messages of type T until untilMessage appears in buffer
* @param subscriptionMessage A lambda to execute before waiting for U
* @param U The server message to wait for before returning the Set of messages
* @param T The type of ServerMessage to fetch
* @return A set of messages of type T in the buffer that arrive before untilMessage appears
*/
suspend inline fun <reified T : ServerMessage, reified U : ServerMessage> fetchMessagesOfType(
timeoutMillis: Long = 5000,
noinline subscriptionMessage: suspend () -> Unit = {},
): List<T> {
val buffer = mutableListOf<T>()
withTimeoutOrNull(timeoutMillis) {
msgBuffer
.onSubscription { subscriptionMessage() }
.takeWhile { it !is U }
.filterIsInstance<T>()
.collect { buffer.add(it) }
} ?: logger.error { "Timed out while waiting to receive ${U::class}" }
return buffer.toList()
} }
fun fetchFeatures() { fun fetchFeatures() {
@@ -50,11 +135,14 @@ class Server(
suspend fun <T : ServerMessage> waitForNext( suspend fun <T : ServerMessage> waitForNext(
kClass: KClass<T>, kClass: KClass<T>,
predicate: (T) -> Boolean = { true }, predicate: (T) -> Boolean = { true },
onSuscribedLambda: suspend () -> Unit = {}, timeoutMillis: Long = 5000,
onSubscribedLambda: suspend () -> Unit = {},
): T = ): T =
withTimeout(timeoutMillis) {
msgBuffer msgBuffer
.onSubscription { onSuscribedLambda() } .onSubscription { onSubscribedLambda() }
.filter { kClass.isInstance(it) } .filter { kClass.isInstance(it) }
.map { kClass.cast(it) } .map { kClass.cast(it) }
.first(predicate) .first(predicate)
}
} }
@@ -25,6 +25,6 @@ class Connection(
writer = MessageWriter(socket, scope, enableLogging) writer = MessageWriter(socket, scope, enableLogging)
reader.start() reader.start()
writer.start() writer.start()
return Server(reader.sharedMessageBuffer, writer.outgoingMessages, enableLogging) return Server(reader.sharedMessageBuffer, writer.outgoingMessages, scope, enableLogging)
} }
} }
@@ -1,6 +1,8 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.net.messages.ServerMessage import com.shr4pnel.ferretirc.net.messages.ServerMessage
import com.shr4pnel.ferretirc.net.messages.ServerMessage.*
import com.shr4pnel.ferretirc.net.messages.ServerMessage.Numeric.*
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger import io.github.oshai.kotlinlogging.slf4j.logger
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@@ -18,7 +20,10 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
abstract class Command(open val command: String, open val parameters: List<String>) { abstract class Command(open val command: String, open val parameters: List<String>) {
abstract fun toServerMessage(): ServerMessage abstract fun toServerMessage(): ServerMessage
fun getTrailingParameterIndex() = parameters.indexOfFirst { it.startsWith(":") } fun getTrailingParameterIndex(): Int {
val index = parameters.indexOfFirst { it.startsWith(":") }
return if (index != -1) index else 0
}
fun getTrailingParameterString() = fun getTrailingParameterString() =
parameters parameters
@@ -26,48 +31,57 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
.joinToString(" ") .joinToString(" ")
.removePrefix(":") .removePrefix(":")
fun getNonTrailingParameterString() = fun getNonTrailingParameterString(startIndex: Int = 0) =
parameters parameters
.subList(0, getTrailingParameterIndex()) .subList(startIndex, getTrailingParameterIndex())
.joinToString(" ") .joinToString(" ")
.removePrefix(":") .removePrefix(":")
fun getNonTrailingParameters() = getNonTrailingParameterString().split(" ") fun getNonTrailingParameters() =
getNonTrailingParameterString()
.split(" ")
class NamedCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) { class NamedCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) {
override fun toServerMessage() = override fun toServerMessage() =
when (command.uppercase()) { when (command.uppercase()) {
"PONG" -> ServerMessage.Pong(getTrailingParameterString().removePrefix(":")) "PONG" -> Pong(getTrailingParameterString().removePrefix(":"))
"MODE" -> Mode(
// TODO MODE, CHANMODE, LOCALMODE parameters.first(), // TODO MODE, CHANMODE, LOCALMODE
"MODE" ->
ServerMessage.Mode(
parameters.first(),
parameters.last().removePrefix(":"), parameters.last().removePrefix(":"),
) )
"NOTICE" -> Notice(
"NOTICE" ->
ServerMessage.Notice(
getNonTrailingParameters(), getNonTrailingParameters(),
getTrailingParameterString(), getTrailingParameterString(),
) )
"PRIVMSG" -> PrivMsg(
"PRIVMSG" ->
ServerMessage.PrivMsg(
getNonTrailingParameters(), getNonTrailingParameters(),
getTrailingParameterString(), getTrailingParameterString(),
) )
"JOIN" -> Join(getTrailingParameterString())
else -> ServerMessage.UNIMPLEMENTED("$command ${parameters.joinToString(" ")}") "CAP" -> Cap(getTrailingParameterString())
"PING" -> Ping(getTrailingParameterString())
"ERROR" -> Error(getTrailingParameterString())
else -> UNIMPLEMENTED("$command ${getTrailingParameterString()}")
} }
} }
class NumericCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) { class NumericCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) {
private enum class RPL(val underlying: Int) { private enum class RPL(val underlying: Int) {
WELCOME(1),
YOURHOST(2),
CREATED(3),
MYINFO(4),
ISUPPORT(5), ISUPPORT(5),
BOUNCE(10),
STATSCOMMANDS(212),
ENDOFSTATS(219),
LISTSTART(321), LISTSTART(321),
LIST(322), LIST(322),
LISTEND(323), LISTEND(323),
TOPIC(332),
TOPICWHOTIME(333),
NAMREPLY(353),
ENDOFNAMES(366),
NONE(0xbeef), ; NONE(0xbeef), ;
companion object { companion object {
@@ -79,38 +93,31 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
override fun toServerMessage() = override fun toServerMessage() =
when (RPL from command) { when (RPL from command) {
RPL.ISUPPORT -> ServerMessage.Numeric.RPL_ISUPPORT(getNonTrailingParameterString()) RPL.WELCOME -> RPL_WELCOME(getTrailingParameterString())
RPL.LISTSTART -> ServerMessage.Numeric.RPL_LISTSTART() RPL.YOURHOST -> RPL_YOURHOST(getTrailingParameterString())
RPL.LIST -> ServerMessage.Numeric.RPL_LIST( RPL.CREATED -> RPL_CREATED(getTrailingParameterString())
RPL.MYINFO -> RPL_MYINFO(getTrailingParameterString())
RPL.BOUNCE -> UNIMPLEMENTED(parameters.toString())
RPL.ISUPPORT -> RPL_ISUPPORT(getNonTrailingParameterString(1).split(" "))
RPL.LISTSTART -> RPL_LISTSTART
RPL.LIST -> RPL_LIST(
parameters[1], parameters[1],
parameters[2].toInt(), parameters[2].toInt(),
getTrailingParameterString(), getTrailingParameterString(),
) )
RPL.LISTEND -> ServerMessage.Numeric.RPL_LISTEND() RPL.LISTEND -> RPL_LISTEND
RPL.NONE -> ServerMessage.UNIMPLEMENTED(parameters.toString()) RPL.STATSCOMMANDS -> UNIMPLEMENTED(parameters.toString())
RPL.ENDOFSTATS -> UNIMPLEMENTED(parameters.toString())
RPL.TOPIC -> RPL_TOPIC(getTrailingParameterString())
RPL.TOPICWHOTIME -> UNIMPLEMENTED(parameters.toString())
RPL.NAMREPLY -> UNIMPLEMENTED(parameters.toString())
RPL.ENDOFNAMES -> RPL_ENDOFNAMES
RPL.NONE -> UNIMPLEMENTED(parameters.toString())
} }
} }
} }
/** /**
* Messages have this format, as rough ABNF:
*
* message ::= ['@' <tags> SPACE] [':' <source> SPACE] <command> <parameters> <crlf>
* SPACE ::= %x20 *( %x20 ) ; space character(s)
* crlf ::= %x0D %x0A ; "carriage return" "linefeed"
*
* The specific parts of an IRC message are:
*
* tags: Optional metadata on a message, starting with ('@', 0x40).
*
* source: Optional note of where the message came from, starting with (':', 0x3A).
*
* command: The specific command this message represents.
*
* parameters: If it exists, data relevant to this specific command.
*
* ===================
*
* Convert line from server into tokens split on strings and remove elements that belong * Convert line from server into tokens split on strings and remove elements that belong
* to different segments of the IRC message e.g. tags, prefixes etc * to different segments of the IRC message e.g. tags, prefixes etc
*/ */
@@ -118,27 +125,24 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
val tokens = commandList.toMutableList() val tokens = commandList.toMutableList()
// remove tags from strlist if present // remove tags from strlist if present
tags = tags = if (tokens.first().startsWith("@")) {
if (tokens.first().startsWith("@")) {
tokens.removeFirst() tokens.removeFirst()
} else { } else {
null null
} }
// remove client prefix (nick&opt hostname) if present // remove client prefix (nick&opt hostname) if present
prefix = prefix = if (tokens.first().startsWith(":")) {
if (tokens.first().startsWith(":")) {
tokens.removeFirst() tokens.removeFirst()
} else { } else {
null null
} }
// get command e.g. PING // get command e.g. PING, 005
val commandStr = tokens.removeFirst() val commandStr = tokens.removeFirst()
// Check if command is a numeric (RPL/ERR) or named // Check if command is a numeric (RPL/ERR) or named
command = command = if (commandStr.toIntOrNull() == null) {
if (commandStr.toIntOrNull() == null) {
Command.NamedCommand(commandStr, tokens) Command.NamedCommand(commandStr, tokens)
} else { } else {
Command.NumericCommand(commandStr, tokens) Command.NumericCommand(commandStr, tokens)
@@ -153,8 +157,14 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
suspend fun start() { suspend fun start() {
val builder = MessageBuilder() val builder = MessageBuilder()
for (msg in incoming) { for (msg in incoming) {
logger.debug { "Receiving: $msg" } logger.trace { "Receiving: $msg" }
incomingParsedMessages.send(builder.build(msg)) val parsed = builder.build(msg)
if (parsed is UNIMPLEMENTED) {
logger.warn { "[S->C]: Left unparsed: $msg" }
} else {
logger.debug { "[S->C]: Got $parsed" }
}
incomingParsedMessages.send(parsed)
} }
} }
} }
@@ -1,6 +1,5 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.net.messages.MessageIO
import com.shr4pnel.ferretirc.net.messages.ServerMessage import com.shr4pnel.ferretirc.net.messages.ServerMessage
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger import io.github.oshai.kotlinlogging.slf4j.logger
@@ -9,13 +8,15 @@ import io.ktor.network.sockets.openReadChannel
import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readLineStrict import io.ktor.utils.io.readLineStrict
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.slf4j.helpers.NOPLogger import org.slf4j.helpers.NOPLogger
class MessageReader(socket: Socket, scope: CoroutineScope, enableLogging: Boolean = false) : MessageIO(socket, scope) { class MessageReader(val socket: Socket, val scope: CoroutineScope, enableLogging: Boolean = false) {
private lateinit var receive: ByteReadChannel private lateinit var receive: ByteReadChannel
val incomingMessages = Channel<String>(Channel.BUFFERED)
private val messageBuffer = MutableSharedFlow<ServerMessage>(0, 64) private val messageBuffer = MutableSharedFlow<ServerMessage>(0, 64)
val sharedMessageBuffer = messageBuffer.asSharedFlow() val sharedMessageBuffer = messageBuffer.asSharedFlow()
val parser = MessageParser(incomingMessages, enableLogging) val parser = MessageParser(incomingMessages, enableLogging)
@@ -31,7 +32,7 @@ class MessageReader(socket: Socket, scope: CoroutineScope, enableLogging: Boolea
} }
} }
override fun start() { fun start() {
scope.launch { scope.launch {
receive = socket.openReadChannel() receive = socket.openReadChannel()
while (true) { while (true) {
@@ -1,6 +1,6 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.net.messages.MessageIO import com.shr4pnel.ferretirc.net.messages.ClientMessage
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger import io.github.oshai.kotlinlogging.slf4j.logger
import io.ktor.network.sockets.Socket import io.ktor.network.sockets.Socket
@@ -8,19 +8,21 @@ import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.ByteWriteChannel
import io.ktor.utils.io.writeFully import io.ktor.utils.io.writeFully
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.slf4j.helpers.NOPLogger import org.slf4j.helpers.NOPLogger
class MessageWriter(socket: Socket, scope: CoroutineScope, enableLogging: Boolean = false) : MessageIO(socket, scope) { class MessageWriter(val socket: Socket, val scope: CoroutineScope, enableLogging: Boolean = false) {
private lateinit var send: ByteWriteChannel private lateinit var send: ByteWriteChannel
private val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER) private val logger = if (enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
val outgoingMessages = Channel<ClientMessage>(Channel.BUFFERED)
override fun start() = fun start() =
scope.launch { scope.launch {
send = socket.openWriteChannel() send = socket.openWriteChannel()
for (msg in outgoingMessages) { for (msg in outgoingMessages) {
logger.debug { "Sending: ${msg.toWireIntermediate()}" } logger.debug { "[C->S]: Sending ${msg.toWireIntermediate()}" }
send.writeFully(msg.toWire()) send.writeFully(msg.toWire())
send.flush() send.flush()
} }
@@ -110,4 +110,9 @@ sealed class ClientMessage(val strName: String) {
override val trailing: String override val trailing: String
get() = message get() = message
} }
class Pong(val token: String) : ClientMessage("PONG") {
override val trailing: String
get() = token
}
} }
@@ -1,12 +0,0 @@
package com.shr4pnel.ferretirc.net.messages
import io.ktor.network.sockets.Socket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
abstract class MessageIO(val socket: Socket, val scope: CoroutineScope) {
val incomingMessages = Channel<String>(Channel.BUFFERED)
val outgoingMessages = Channel<ClientMessage>(Channel.BUFFERED)
abstract fun start(): Any
}
@@ -4,25 +4,28 @@ package com.shr4pnel.ferretirc.net.messages
* Representation of responses from the IRC server * Representation of responses from the IRC server
*/ */
sealed class ServerMessage { sealed class ServerMessage {
data class Cap(val placeholder: String? = null) : ServerMessage() data class Cap(val capabilities: String) : ServerMessage()
data class Pong(val token: String? = null) : ServerMessage() data class Pong(val token: String? = null) : ServerMessage()
data class Mode(val operatorName: String, val pMask: String) : ServerMessage() data class Mode(val operatorName: String, val pMask: String) : ServerMessage()
data class PrivMsg(val targets: List<String>, val message: String) : ServerMessage() data class PrivMsg(val targets: List<String>, val message: String) : ServerMessage()
data class Notice(val targets: List<String>, val message: String) : ServerMessage() data class Notice(val targets: List<String>, val message: String) : ServerMessage()
data class UNIMPLEMENTED(val msg: String) : ServerMessage() data class UNIMPLEMENTED(val msg: String) : ServerMessage()
data class Join(val target: String) : ServerMessage()
data class Ping(val token: String) : ServerMessage()
data class Error(val message: String) : ServerMessage()
sealed class Numeric(val number: Short) : ServerMessage() { sealed class Numeric(val number: Short) : ServerMessage() {
data class RPL_ISUPPORT(val keypairs: String) : Numeric(5) data class RPL_WELCOME(val message: String) : Numeric(1)
data class RPL_YOURHOST(val message: String) : Numeric(2)
class RPL_LISTSTART : Numeric(321) data class RPL_CREATED(val message: String) : Numeric(3)
data class RPL_MYINFO(val message: String) : Numeric(4)
data class RPL_ISUPPORT(val keypairs: List<String>) : Numeric(5)
data class RPL_LUSER(val message: String) : Numeric(251)
data object RPL_LISTSTART : Numeric(321)
data class RPL_LIST(val chanName: String, val clientCount: Int, val topic: String) : Numeric(322) data class RPL_LIST(val chanName: String, val clientCount: Int, val topic: String) : Numeric(322)
data object RPL_LISTEND : Numeric(323)
class RPL_LISTEND : Numeric(323) data class RPL_TOPIC(val topic: String) : Numeric(332)
data class RPL_NAMREPLY(val message: String) : Numeric(353)
data object RPL_ENDOFNAMES : Numeric(366)
} }
} }
@@ -26,8 +26,8 @@ object Codes {
const val BG_START = ANSI.ESC_START + "48;5;" const val BG_START = ANSI.ESC_START + "48;5;"
const val RED = "9" const val RED = "9"
const val BLACK = "16" const val BLACK = "16"
const val GREEN = "22" const val BLUE = "26"
const val BLUE = "27" const val GREEN = "28"
const val ORANGE = "202" const val ORANGE = "202"
const val DIMMED_WHITE = "250" const val DIMMED_WHITE = "250"
const val WHITE = "255" const val WHITE = "255"
+3 -3
View File
@@ -1,11 +1,11 @@
<included> <included>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/> <import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/>
<import class="ch.qos.logback.core.ConsoleAppender"/> <import class="ch.qos.logback.core.ConsoleAppender"/>
<import class="ch.qos.logback.classic.filter.ThresholdFilter"/>
<import class="ch.qos.logback.core.FileAppender"/> <import class="ch.qos.logback.core.FileAppender"/>
<import class="com.shr4pnel.ferretirc.util.HighlightingCompositeConverterExt"/> <import class="com.shr4pnel.ferretirc.util.HighlightingCompositeConverterExt"/>
<import class="com.shr4pnel.ferretirc.util.ThreadConverter"/> <import class="com.shr4pnel.ferretirc.util.ThreadConverter"/>
<import class="com.shr4pnel.ferretirc.util.BoldWhiteCompositeConverterExt"/> <import class="com.shr4pnel.ferretirc.util.BoldWhiteCompositeConverterExt"/>
<import class="ch.qos.logback.classic.filter.ThresholdFilter"/>
<!-- https://logback.qos.ch/manual/layouts.html#formatModifiers --> <!-- https://logback.qos.ch/manual/layouts.html#formatModifiers -->
<conversionRule conversionWord="highlightext" class="HighlightingCompositeConverterExt" /> <conversionRule conversionWord="highlightext" class="HighlightingCompositeConverterExt" />
<conversionRule conversionWord="boldwhiteext" class="BoldWhiteCompositeConverterExt" /> <conversionRule conversionWord="boldwhiteext" class="BoldWhiteCompositeConverterExt" />
@@ -15,7 +15,7 @@
<level>${STDOUT_LEVEL:-debug}</level> <level>${STDOUT_LEVEL:-debug}</level>
</filter> </filter>
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<pattern>%d{HH:mm:ss.SSS} %highlightext(%level) %-13.13threadconvert(%t) %-36.36logger %boldwhiteext(%msg) %n</pattern> <pattern>%d{ss.SSS} %highlightext(%level) %green(%-13.13threadconvert(%t)) %magenta(%-36.36logger) %boldwhiteext(%msg) %n</pattern>
</encoder> </encoder>
</appender> </appender>
<appender name="FILE" class="FileAppender"> <appender name="FILE" class="FileAppender">
@@ -23,7 +23,7 @@
<immediateFlush>true</immediateFlush> <immediateFlush>true</immediateFlush>
<append>false</append> <append>false</append>
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<pattern>%d{HH:mm:ss.SSS} [%.16t/%-5level] %logger %msg%n</pattern> <pattern>%d{HH:mm:ss.SSS} %-20.20([%threadconvert(%t)/%level]) %logger %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
</included> </included>
@@ -0,0 +1,38 @@
package com.shr4pnel.ferretirc
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.BeforeAll
import java.io.File
class IRCErrTest {
companion object {
lateinit var processes: MutableList<Process>
val client = IrcClient("localhost", 6667, enableLogging = true)
private val logger = KotlinLogging.logger {}
@JvmStatic
@BeforeAll
fun setup(): Unit =
runBlocking {
val logFile = File("src/test/resources/logs/ngircd.log")
val ngircdConfigPath = javaClass.classLoader.getResource("ngircd.conf")!!.path
logFile.createNewFile()
processes =
ProcessBuilder.startPipeline(
listOf<ProcessBuilder>(
// Run IRC server in [-d] debug log mode, with [-n] no daemon, using config [-f] file at path
ProcessBuilder("ngircd", "-dnf", ngircdConfigPath).redirectErrorStream(true),
// Remove unhelpful default ngircd info [PID. relative time, GID?]
ProcessBuilder("sed", "-ue", "s/\\[[^][]*\\]//g"),
// Add relative timestamp
ProcessBuilder("ts", "-s", "%H:%M:%.S").redirectOutput(logFile),
),
)
client.connect()
client.register("shr4p", "Tyler Fullname", "password")
val default = client.server.fetchChannels().first()
client.join(default)
}
}
}
@@ -3,7 +3,6 @@ package com.shr4pnel.ferretirc
import com.shr4pnel.ferretirc.net.messages.ServerMessage import com.shr4pnel.ferretirc.net.messages.ServerMessage
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -43,7 +42,9 @@ class IrcClientTest {
client.register("shr4p", "Tyler Fullname", "password") client.register("shr4p", "Tyler Fullname", "password")
guestUser.connect() guestUser.connect()
guestUser.register("guest", "guest", "pass") guestUser.register("guest", "guest", "pass")
client.server val default = client.server.fetchChannels().first()
client.join(default)
guestUser.join(default)
} }
@JvmStatic @JvmStatic
@@ -59,38 +60,37 @@ class IrcClientTest {
@Test @Test
fun pingGetsPong() = fun pingGetsPong() =
runTest { runBlocking {
val token = "ACK" val token = "ACK"
val msg = Message.Ping(token) val msg = Message.Ping(token)
val pong = client.queueAndWaitForNext<ServerMessage.Pong>(msg) val pong = client.queueMessageAndWaitForNext<ServerMessage.Pong>(msg)
assertEquals(token, pong.token, "Token in PING did not match PONG") assertEquals(token, pong.token, "Token in PING did not match PONG")
} }
@Test @Test
fun oper() = fun oper() =
runTest { runBlocking {
client.queueMessage(Message.Oper("shr4p", "password")) val mode = client.queueMessageAndWaitForNext<ServerMessage.Mode>(Message.Oper("shr4p", "password"))
val mode = client.waitForNext<ServerMessage.Mode>()
assertEquals("shr4p", mode.operatorName, "Received incorrect operator name in MODE") assertEquals("shr4p", mode.operatorName, "Received incorrect operator name in MODE")
assertEquals("+o", mode.pMask, "Received unexpected mask in MODE") assertEquals("+o", mode.pMask, "Received unexpected mask in MODE")
logger.info { "Received MODE with mask ${mode.pMask}" } logger.info { "Received MODE with mask ${mode.pMask}" }
} }
@Test @Test
fun listChannels() = fun listChannels() = runBlocking {
runTest { assert(
val channels = client.server.fetchChannels() client.server.channels.value.isNotEmpty(),
assert(channels.isNotEmpty()) )
client.queueMessage(Message.Join(channels.first()))
} }
@Test @Test
fun messageLands() = fun messageLands() =
runTest { runBlocking {
val channels = client.server.fetchChannels() val channels = client.server.channels.value
val message = Message.PrivMsg(channels.first(), "bring me to life") val message = Message.PrivMsg(channels.first(), "bring me to life")
val receivedMessage = client.waitForNextMessageAfterAction<ServerMessage.PrivMsg> {
guestUser.queueMessage(message) guestUser.queueMessage(message)
val receivedMessage = client.waitForNext<ServerMessage.PrivMsg>() }
logger.debug { "Message: $message, Received: $receivedMessage" } logger.debug { "Message: $message, Received: $receivedMessage" }
assertEquals(message.message, receivedMessage.message) assertEquals(message.message, receivedMessage.message)
assertEquals(message.target.chanName, receivedMessage.targets.first()) assertEquals(message.target.chanName, receivedMessage.targets.first())
@@ -98,8 +98,8 @@ class IrcClientTest {
@Test @Test
fun manyMessagesLand() = fun manyMessagesLand() =
runTest { runBlocking {
val channels = client.server.fetchChannels() val channels = client.server.channels.value
val channel = channels.first() val channel = channels.first()
val max = 100 val max = 100
@@ -109,7 +109,9 @@ class IrcClientTest {
for (i in 1..max) messages.add(Message.PrivMsg(channel, "$i: bring me to life")) for (i in 1..max) messages.add(Message.PrivMsg(channel, "$i: bring me to life"))
for (i in 0..<max) { for (i in 0..<max) {
val msg = client.waitForNextAfterAction<ServerMessage.PrivMsg> { guestUser.queueMessage(messages[i]) } val msg = client.waitForNextMessageAfterAction<ServerMessage.PrivMsg> {
guestUser.queueMessage(messages[i])
}
receivedMessages.add(msg) receivedMessages.add(msg)
} }
for (i in 0..<max) assertEquals(messages[i].message, receivedMessages[i].message) for (i in 0..<max) assertEquals(messages[i].message, receivedMessages[i].message)
+1 -1
View File
@@ -49,7 +49,7 @@
PAM = no PAM = no
PAMIsOptional = yes PAMIsOptional = yes
PAMServiceName = ngircd PAMServiceName = ngircd
RequireAuthPing = no RequireAuthPing = yes
ScrubCTCP = no ScrubCTCP = no
SyslogFacility = local5 SyslogFacility = local5
WebircPassword = WebircPassword =