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_standard_property-naming = 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/.gitkeep
.idea
todo.md
@@ -1,5 +1,6 @@
package com.shr4pnel.ferretirc
import com.shr4pnel.ferretirc.irc.IRCChannel
import com.shr4pnel.ferretirc.irc.Server
import com.shr4pnel.ferretirc.irc.User
import com.shr4pnel.ferretirc.net.Connection
@@ -17,13 +18,13 @@ import org.slf4j.helpers.NOPLogger
class IrcClient(
hostname: String,
port: Int,
var enableLogging: Boolean = false,
val enableLogging: Boolean = false,
) {
init {
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)
lateinit var server: Server
@@ -43,14 +44,15 @@ class IrcClient(
}
/**
* Send a message to the IRC server writer
* This is asynchronous and ordered
* Send a message to the IRC server message writer
* @param message The client message to send to the server
*/
suspend fun queueMessage(message: ClientMessage) {
connection.writer.outgoingMessages.send(message)
}
suspend fun join(channel: IRCChannel) = queueMessage(ClientMessage.Join(channel))
/**
* Send a message to the IRC server writer
* This is asynchronous and ordered
@@ -85,7 +87,7 @@ class IrcClient(
* @param predicate A function to filter a message based on its parameters
* @see com.shr4pnel.ferretirc.IrcClient.waitForNext
*/
suspend inline fun <reified T : ServerMessage> queueAndWaitForNext(
suspend inline fun <reified T : ServerMessage> queueMessageAndWaitForNext(
message: ClientMessage,
noinline predicate: (T) -> Boolean = { true },
): 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
* @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 action: suspend () -> Unit,
): T = server.waitForNext(T::class, predicate, action)
): T = server.waitForNext(T::class, predicate) { action() }
suspend fun register(
nick: String,
@@ -119,6 +121,6 @@ class IrcClient(
}
add(ClientMessage.Cap.END())
}.toTypedArray()
queueMessages(*messages)
waitForNextMessageAfterAction<ServerMessage.Numeric.RPL_WELCOME> { queueMessages(*messages) }
}
}
@@ -1,3 +1,3 @@
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
data class ISupportFeatures(val capabilities: List<Feature>)
sealed class Feature(key: String) {
sealed class ISupportFeature(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).
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.
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
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".
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).
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.
data class MAXNICKLEN(val max: Int) : Feature("MAXNICKLEN")
/** 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) : 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.ServerMessage
import com.shr4pnel.ferretirc.net.messages.ServerMessage.Numeric
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.slf4j.helpers.NOPLogger
import kotlin.reflect.KClass
import kotlin.reflect.cast
class Server(
private val msgBuffer: SharedFlow<ServerMessage>,
private val outgoingMessages: Channel<ClientMessage>,
@PublishedApi internal val msgBuffer: SharedFlow<ServerMessage>,
@PublishedApi internal val outgoingMessages: Channel<ClientMessage>,
private val scope: CoroutineScope,
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 set
private val _features = MutableStateFlow<List<ISupportFeature>>(emptyList())
val features: StateFlow<List<ISupportFeature>> = _features.asStateFlow()
var channels: Set<IRCChannel> = setOf()
private set
/** Backing field for channels storing the current state of visible channels */
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" }
val buffer = mutableSetOf<IRCChannel>()
val buffer = mutableListOf<IRCChannel>()
msgBuffer
.onSubscription { outgoingMessages.send(ClientMessage.List()) }
.filterIsInstance<ServerMessage.Numeric>()
.takeWhile { it !is ServerMessage.Numeric.RPL_LISTEND }
.filterIsInstance<ServerMessage.Numeric.RPL_LIST>()
.filterIsInstance<Numeric>()
.takeWhile { it !is Numeric.RPL_LISTEND }
.filterIsInstance<Numeric.RPL_LIST>()
.collect { buffer.add(IRCChannel(it.chanName, it.clientCount, it.topic)) }
channels = buffer.toSortedSet(compareBy { it.chanName })
return channels
val sortedChannels = buffer.sortedBy { it.chanName }
_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() {
@@ -50,11 +135,14 @@ class Server(
suspend fun <T : ServerMessage> waitForNext(
kClass: KClass<T>,
predicate: (T) -> Boolean = { true },
onSuscribedLambda: suspend () -> Unit = {},
timeoutMillis: Long = 5000,
onSubscribedLambda: suspend () -> Unit = {},
): T =
withTimeout(timeoutMillis) {
msgBuffer
.onSubscription { onSuscribedLambda() }
.onSubscription { onSubscribedLambda() }
.filter { kClass.isInstance(it) }
.map { kClass.cast(it) }
.first(predicate)
}
}
@@ -25,6 +25,6 @@ class Connection(
writer = MessageWriter(socket, scope, enableLogging)
reader.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
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.slf4j.logger
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 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() =
parameters
@@ -26,48 +31,57 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
.joinToString(" ")
.removePrefix(":")
fun getNonTrailingParameterString() =
fun getNonTrailingParameterString(startIndex: Int = 0) =
parameters
.subList(0, getTrailingParameterIndex())
.subList(startIndex, getTrailingParameterIndex())
.joinToString(" ")
.removePrefix(":")
fun getNonTrailingParameters() = getNonTrailingParameterString().split(" ")
fun getNonTrailingParameters() =
getNonTrailingParameterString()
.split(" ")
class NamedCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) {
override fun toServerMessage() =
when (command.uppercase()) {
"PONG" -> ServerMessage.Pong(getTrailingParameterString().removePrefix(":"))
// TODO MODE, CHANMODE, LOCALMODE
"MODE" ->
ServerMessage.Mode(
parameters.first(),
"PONG" -> Pong(getTrailingParameterString().removePrefix(":"))
"MODE" -> Mode(
parameters.first(), // TODO MODE, CHANMODE, LOCALMODE
parameters.last().removePrefix(":"),
)
"NOTICE" ->
ServerMessage.Notice(
"NOTICE" -> Notice(
getNonTrailingParameters(),
getTrailingParameterString(),
)
"PRIVMSG" ->
ServerMessage.PrivMsg(
"PRIVMSG" -> PrivMsg(
getNonTrailingParameters(),
getTrailingParameterString(),
)
else -> ServerMessage.UNIMPLEMENTED("$command ${parameters.joinToString(" ")}")
"JOIN" -> Join(getTrailingParameterString())
"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) {
private enum class RPL(val underlying: Int) {
WELCOME(1),
YOURHOST(2),
CREATED(3),
MYINFO(4),
ISUPPORT(5),
BOUNCE(10),
STATSCOMMANDS(212),
ENDOFSTATS(219),
LISTSTART(321),
LIST(322),
LISTEND(323),
TOPIC(332),
TOPICWHOTIME(333),
NAMREPLY(353),
ENDOFNAMES(366),
NONE(0xbeef), ;
companion object {
@@ -79,38 +93,31 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
override fun toServerMessage() =
when (RPL from command) {
RPL.ISUPPORT -> ServerMessage.Numeric.RPL_ISUPPORT(getNonTrailingParameterString())
RPL.LISTSTART -> ServerMessage.Numeric.RPL_LISTSTART()
RPL.LIST -> ServerMessage.Numeric.RPL_LIST(
RPL.WELCOME -> RPL_WELCOME(getTrailingParameterString())
RPL.YOURHOST -> RPL_YOURHOST(getTrailingParameterString())
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[2].toInt(),
getTrailingParameterString(),
)
RPL.LISTEND -> ServerMessage.Numeric.RPL_LISTEND()
RPL.NONE -> ServerMessage.UNIMPLEMENTED(parameters.toString())
RPL.LISTEND -> RPL_LISTEND
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
* 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()
// remove tags from strlist if present
tags =
if (tokens.first().startsWith("@")) {
tags = if (tokens.first().startsWith("@")) {
tokens.removeFirst()
} else {
null
}
// remove client prefix (nick&opt hostname) if present
prefix =
if (tokens.first().startsWith(":")) {
prefix = if (tokens.first().startsWith(":")) {
tokens.removeFirst()
} else {
null
}
// get command e.g. PING
// get command e.g. PING, 005
val commandStr = tokens.removeFirst()
// Check if command is a numeric (RPL/ERR) or named
command =
if (commandStr.toIntOrNull() == null) {
command = if (commandStr.toIntOrNull() == null) {
Command.NamedCommand(commandStr, tokens)
} else {
Command.NumericCommand(commandStr, tokens)
@@ -153,8 +157,14 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
suspend fun start() {
val builder = MessageBuilder()
for (msg in incoming) {
logger.debug { "Receiving: $msg" }
incomingParsedMessages.send(builder.build(msg))
logger.trace { "Receiving: $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
import com.shr4pnel.ferretirc.net.messages.MessageIO
import com.shr4pnel.ferretirc.net.messages.ServerMessage
import io.github.oshai.kotlinlogging.KotlinLogging
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.readLineStrict
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
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
val incomingMessages = Channel<String>(Channel.BUFFERED)
private val messageBuffer = MutableSharedFlow<ServerMessage>(0, 64)
val sharedMessageBuffer = messageBuffer.asSharedFlow()
val parser = MessageParser(incomingMessages, enableLogging)
@@ -31,7 +32,7 @@ class MessageReader(socket: Socket, scope: CoroutineScope, enableLogging: Boolea
}
}
override fun start() {
fun start() {
scope.launch {
receive = socket.openReadChannel()
while (true) {
@@ -1,6 +1,6 @@
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.slf4j.logger
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.writeFully
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
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 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 {
send = socket.openWriteChannel()
for (msg in outgoingMessages) {
logger.debug { "Sending: ${msg.toWireIntermediate()}" }
logger.debug { "[C->S]: Sending ${msg.toWireIntermediate()}" }
send.writeFully(msg.toWire())
send.flush()
}
@@ -110,4 +110,9 @@ sealed class ClientMessage(val strName: String) {
override val trailing: String
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
*/
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 Mode(val operatorName: String, val pMask: 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 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() {
data class RPL_ISUPPORT(val keypairs: String) : Numeric(5)
class RPL_LISTSTART : Numeric(321)
data class RPL_WELCOME(val message: String) : Numeric(1)
data class RPL_YOURHOST(val message: String) : Numeric(2)
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)
class RPL_LISTEND : Numeric(323)
data object 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 RED = "9"
const val BLACK = "16"
const val GREEN = "22"
const val BLUE = "27"
const val BLUE = "26"
const val GREEN = "28"
const val ORANGE = "202"
const val DIMMED_WHITE = "250"
const val WHITE = "255"
+3 -3
View File
@@ -1,11 +1,11 @@
<included>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/>
<import class="ch.qos.logback.core.ConsoleAppender"/>
<import class="ch.qos.logback.classic.filter.ThresholdFilter"/>
<import class="ch.qos.logback.core.FileAppender"/>
<import class="com.shr4pnel.ferretirc.util.HighlightingCompositeConverterExt"/>
<import class="com.shr4pnel.ferretirc.util.ThreadConverter"/>
<import class="com.shr4pnel.ferretirc.util.BoldWhiteCompositeConverterExt"/>
<import class="ch.qos.logback.classic.filter.ThresholdFilter"/>
<!-- https://logback.qos.ch/manual/layouts.html#formatModifiers -->
<conversionRule conversionWord="highlightext" class="HighlightingCompositeConverterExt" />
<conversionRule conversionWord="boldwhiteext" class="BoldWhiteCompositeConverterExt" />
@@ -15,7 +15,7 @@
<level>${STDOUT_LEVEL:-debug}</level>
</filter>
<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>
</appender>
<appender name="FILE" class="FileAppender">
@@ -23,7 +23,7 @@
<immediateFlush>true</immediateFlush>
<append>false</append>
<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>
</appender>
</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 io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
@@ -43,7 +42,9 @@ class IrcClientTest {
client.register("shr4p", "Tyler Fullname", "password")
guestUser.connect()
guestUser.register("guest", "guest", "pass")
client.server
val default = client.server.fetchChannels().first()
client.join(default)
guestUser.join(default)
}
@JvmStatic
@@ -59,38 +60,37 @@ class IrcClientTest {
@Test
fun pingGetsPong() =
runTest {
runBlocking {
val token = "ACK"
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")
}
@Test
fun oper() =
runTest {
client.queueMessage(Message.Oper("shr4p", "password"))
val mode = client.waitForNext<ServerMessage.Mode>()
runBlocking {
val mode = client.queueMessageAndWaitForNext<ServerMessage.Mode>(Message.Oper("shr4p", "password"))
assertEquals("shr4p", mode.operatorName, "Received incorrect operator name in MODE")
assertEquals("+o", mode.pMask, "Received unexpected mask in MODE")
logger.info { "Received MODE with mask ${mode.pMask}" }
}
@Test
fun listChannels() =
runTest {
val channels = client.server.fetchChannels()
assert(channels.isNotEmpty())
client.queueMessage(Message.Join(channels.first()))
fun listChannels() = runBlocking {
assert(
client.server.channels.value.isNotEmpty(),
)
}
@Test
fun messageLands() =
runTest {
val channels = client.server.fetchChannels()
runBlocking {
val channels = client.server.channels.value
val message = Message.PrivMsg(channels.first(), "bring me to life")
val receivedMessage = client.waitForNextMessageAfterAction<ServerMessage.PrivMsg> {
guestUser.queueMessage(message)
val receivedMessage = client.waitForNext<ServerMessage.PrivMsg>()
}
logger.debug { "Message: $message, Received: $receivedMessage" }
assertEquals(message.message, receivedMessage.message)
assertEquals(message.target.chanName, receivedMessage.targets.first())
@@ -98,8 +98,8 @@ class IrcClientTest {
@Test
fun manyMessagesLand() =
runTest {
val channels = client.server.fetchChannels()
runBlocking {
val channels = client.server.channels.value
val channel = channels.first()
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 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)
}
for (i in 0..<max) assertEquals(messages[i].message, receivedMessages[i].message)
+1 -1
View File
@@ -49,7 +49,7 @@
PAM = no
PAMIsOptional = yes
PAMServiceName = ngircd
RequireAuthPing = no
RequireAuthPing = yes
ScrubCTCP = no
SyslogFacility = local5
WebircPassword =