Compare commits
14
Commits
c728a612bb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
357405aba5
|
||
|
|
96d0c084b1
|
||
|
|
736b7b37df
|
||
|
|
0c3e3013cd
|
||
|
|
281b8fff2a
|
||
|
|
5575d96f93
|
||
|
|
06e0ff92de
|
||
|
|
d3deed36db
|
||
|
|
81efc6dcc3
|
||
|
|
4602af4e44
|
||
|
|
0bf6b0dae0
|
||
|
|
97fc15c7b2
|
||
|
|
6c13b6ecc6
|
||
|
|
796bd814e1
|
+3
-1
@@ -7,4 +7,6 @@ ktlint_standard_multiline-expression-wrapping = disabled
|
||||
ktlint_standard_multiline-loop = disabled
|
||||
ktlint_function_signature_body_expression_wrapping = default
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
+11
-10
@@ -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 =
|
||||
msgBuffer
|
||||
.onSubscription { onSuscribedLambda() }
|
||||
.filter { kClass.isInstance(it) }
|
||||
.map { kClass.cast(it) }
|
||||
.first(predicate)
|
||||
withTimeout(timeoutMillis) {
|
||||
msgBuffer
|
||||
.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(),
|
||||
parameters.last().removePrefix(":"),
|
||||
)
|
||||
|
||||
"NOTICE" ->
|
||||
ServerMessage.Notice(
|
||||
getNonTrailingParameters(),
|
||||
getTrailingParameterString(),
|
||||
)
|
||||
|
||||
"PRIVMSG" ->
|
||||
ServerMessage.PrivMsg(
|
||||
getNonTrailingParameters(),
|
||||
getTrailingParameterString(),
|
||||
)
|
||||
|
||||
else -> ServerMessage.UNIMPLEMENTED("$command ${parameters.joinToString(" ")}")
|
||||
"PONG" -> Pong(getTrailingParameterString().removePrefix(":"))
|
||||
"MODE" -> Mode(
|
||||
parameters.first(), // TODO MODE, CHANMODE, LOCALMODE
|
||||
parameters.last().removePrefix(":"),
|
||||
)
|
||||
"NOTICE" -> Notice(
|
||||
getNonTrailingParameters(),
|
||||
getTrailingParameterString(),
|
||||
)
|
||||
"PRIVMSG" -> PrivMsg(
|
||||
getNonTrailingParameters(),
|
||||
getTrailingParameterString(),
|
||||
)
|
||||
"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,31 +125,28 @@ class MessageParser(val incoming: Channel<String>, enableLogging: Boolean = fals
|
||||
val tokens = commandList.toMutableList()
|
||||
|
||||
// remove tags from strlist if present
|
||||
tags =
|
||||
if (tokens.first().startsWith("@")) {
|
||||
tokens.removeFirst()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
tags = if (tokens.first().startsWith("@")) {
|
||||
tokens.removeFirst()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// remove client prefix (nick&opt hostname) if present
|
||||
prefix =
|
||||
if (tokens.first().startsWith(":")) {
|
||||
tokens.removeFirst()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
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.NamedCommand(commandStr, tokens)
|
||||
} else {
|
||||
Command.NumericCommand(commandStr, tokens)
|
||||
}
|
||||
command = if (commandStr.toIntOrNull() == null) {
|
||||
Command.NamedCommand(commandStr, tokens)
|
||||
} else {
|
||||
Command.NumericCommand(commandStr, tokens)
|
||||
}
|
||||
|
||||
return command.toServerMessage()
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
guestUser.queueMessage(message)
|
||||
val receivedMessage = client.waitForNext<ServerMessage.PrivMsg>()
|
||||
val receivedMessage = client.waitForNextMessageAfterAction<ServerMessage.PrivMsg> {
|
||||
guestUser.queueMessage(message)
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
PAM = no
|
||||
PAMIsOptional = yes
|
||||
PAMServiceName = ngircd
|
||||
RequireAuthPing = no
|
||||
RequireAuthPing = yes
|
||||
ScrubCTCP = no
|
||||
SyslogFacility = local5
|
||||
WebircPassword =
|
||||
|
||||
Reference in New Issue
Block a user