Compare commits

...
13 Commits
16 changed files with 306 additions and 127 deletions
+5 -5
View File
@@ -6,7 +6,7 @@ plugins {
group = "com.shr4pnel" group = "com.shr4pnel"
version = "1.0-SNAPSHOT" version = "1.0-SNAPSHOT"
val ktor_version = "3.5.2" val ktorVersion = "3.5.2"
repositories { repositories {
mavenCentral() mavenCentral()
@@ -14,10 +14,10 @@ repositories {
dependencies { dependencies {
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
implementation ("io.ktor:ktor-client-core:${ktor_version}") implementation ("io.ktor:ktor-client-core:${ktorVersion}")
implementation("io.ktor:ktor-client-cio:${ktor_version}") implementation("io.ktor:ktor-client-cio:${ktorVersion}")
implementation("io.ktor:ktor-network:${ktor_version}") implementation("io.ktor:ktor-network:${ktorVersion}")
implementation("io.ktor:ktor-network-tls:${ktor_version}") implementation("io.ktor:ktor-network-tls:${ktorVersion}")
implementation("io.github.oshai:kotlin-logging-jvm:8.0.4") implementation("io.github.oshai:kotlin-logging-jvm:8.0.4")
implementation("ch.qos.logback:logback-classic:1.6.1") implementation("ch.qos.logback:logback-classic:1.6.1")
} }
@@ -1,5 +1,7 @@
package com.shr4pnel.ferretirc package com.shr4pnel.ferretirc
import com.shr4pnel.ferretirc.irc.Server
import com.shr4pnel.ferretirc.irc.util.Helpers
import com.shr4pnel.ferretirc.net.messages.ClientMessage import com.shr4pnel.ferretirc.net.messages.ClientMessage
import com.shr4pnel.ferretirc.net.Connection import com.shr4pnel.ferretirc.net.Connection
import com.shr4pnel.ferretirc.net.messages.ServerMessage import com.shr4pnel.ferretirc.net.messages.ServerMessage
@@ -9,30 +11,48 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
class IrcClient(hostname: String, port: Int) { class IrcClient(hostname: String, port: Int, enableLogging: Boolean = true) {
init { init {
KotlinLoggingConfiguration.logStartupMessage = false KotlinLoggingConfiguration.logStartupMessage = false
Helpers.enableLogging = enableLogging
} }
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
lateinit var server: Server
@PublishedApi @PublishedApi
internal val connection = Connection(hostname, port, scope) internal val connection = Connection(hostname, port, scope)
/**
* Connect to the socket
*/
suspend fun connect() { suspend fun connect() {
connection.connect() server = connection.connect()
} }
/**
* Send a message to the IRC server writer
* This is asynchronous and ordered
* @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)
} }
/**
* Send a message to the IRC server writer
* This is asynchronous and ordered
* @param messages The client messages to send to the server
*/
suspend fun queueMessages(vararg messages: ClientMessage) { suspend fun queueMessages(vararg messages: ClientMessage) {
messages.forEach { messages.forEach {
connection.writer.outgoingMessages.send(it) connection.writer.outgoingMessages.send(it)
} }
} }
/**
* Close the coroutine scope controlling listeners, readers, writers etc
*/
fun close() { fun close() {
scope.cancel() scope.cancel()
} }
@@ -0,0 +1,5 @@
package com.shr4pnel.ferretirc.irc
class IRCChannel(val chanName: String, val clientCount: Int, val topic: String) {
}
@@ -0,0 +1,23 @@
package com.shr4pnel.ferretirc.irc
data class ISupportFeatures(val capabilities: List<Feature>)
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).
data class ACCEPT(val max: Int) : Feature("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")
// 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")
// 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")
// Indicates the method thats 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")
// 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")
}
@@ -0,0 +1,36 @@
package com.shr4pnel.ferretirc.irc
import com.shr4pnel.ferretirc.net.messages.ClientMessage
import com.shr4pnel.ferretirc.net.messages.ServerMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.flow.takeWhile
class Server(private val msgBuffer: SharedFlow<ServerMessage>, private val outgoingMessages: Channel<ClientMessage>, private val scope: CoroutineScope) {
lateinit var features: ISupportFeatures
private set
var channels: Set<IRCChannel> = setOf()
private set
suspend fun fetchChannels(): Set<IRCChannel> {
val buffer = mutableSetOf<IRCChannel>()
msgBuffer
.onSubscription { outgoingMessages.send(ClientMessage.List()) }
.filterIsInstance<ServerMessage.Numeric>()
.takeWhile { it !is ServerMessage.Numeric.RPL_LISTEND }
.filterIsInstance<ServerMessage.Numeric.RPL_LIST>()
.collect { buffer.add(IRCChannel(it.chanName, it.clientCount, it.topic)) }
channels = buffer.toSortedSet(compareBy { it.chanName })
return channels
}
fun fetchFeatures() {
}
}
@@ -0,0 +1,7 @@
package com.shr4pnel.ferretirc.irc.util
class Helpers {
companion object LoggingConfig {
var enableLogging = true
}
}
@@ -1,21 +1,25 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.net.messages.ClientMessage import com.shr4pnel.ferretirc.irc.Server
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.Socket import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket import io.ktor.network.sockets.aSocket
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
class Connection(val hostname: String, val port: Int, val scope: CoroutineScope) { class Connection(val hostname: String, val port: Int, val scope: CoroutineScope) {
private val socketBuilder = aSocket(ClientMessage.selectorManager).tcp() private val selectorManager = SelectorManager(Dispatchers.IO)
private val socketBuilder = aSocket(selectorManager).tcp()
lateinit var socket: Socket lateinit var socket: Socket
lateinit var reader: MessageReader lateinit var reader: MessageReader
lateinit var writer: MessageWriter lateinit var writer: MessageWriter
suspend fun connect() { suspend fun connect(): Server {
socket = socketBuilder.connect(hostname, port) socket = socketBuilder.connect(hostname, port)
reader = MessageReader(socket, scope) reader = MessageReader(socket, scope)
writer = MessageWriter(socket, scope) writer = MessageWriter(socket, scope)
reader.start() reader.start()
writer.start() writer.start()
return Server(reader.sharedMessageBuffer, writer.outgoingMessages, scope)
} }
} }
@@ -1,75 +1,78 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.irc.util.Helpers
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.ktor.http.parameters import io.github.oshai.kotlinlogging.slf4j.logger
import io.ktor.network.selector.SelectInterest
import io.ktor.util.toUpperCasePreservingASCIIRules
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import org.slf4j.helpers.NOPLogger
class MessageParser(val incoming: Channel<String>) { class MessageParser(val incoming: Channel<String>) {
private val logger = KotlinLogging.logger("MessageParser")
val incomingParsedMessages = Channel<ServerMessage>() val incomingParsedMessages = Channel<ServerMessage>()
private val logger = if (Helpers.enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
private companion object MessageBuilder { class MessageBuilder {
val logger = KotlinLogging.logger("MessageParser.MessageBuilder")
fun getTrailingParameterIndex(params: List<String>) = params.indexOfLast { it.startsWith(":") }
sealed interface Command {
fun toServerMessage(): ServerMessage
class NamedCommand(val name: String, val parameters: List<String>) : Command {
override fun toServerMessage(): ServerMessage {
logger.debug { "Parsing ${name.toUpperCasePreservingASCIIRules()}, with params $parameters" }
return when (name.toUpperCasePreservingASCIIRules()) {
// PONG :PREFIX COMMAND HOSTNAME :TOKEN
"PONG" -> {
val tokenIndex = getTrailingParameterIndex(parameters)
if (tokenIndex != -1) ServerMessage.Pong(
parameters.subList(tokenIndex, parameters.size).joinToString(" ").removePrefix(":")
)
else ServerMessage.Pong()
}
// MODE :PREFIX NICK :MODES
"MODE" -> {
ServerMessage.Mode(parameters.first(), parameters.last().removePrefix(":")) // TODO MODE, CHANMODE, LOCALMODE
}
"NOTICE", "PRIVMSG" -> {
val cmd = name.toUpperCasePreservingASCIIRules()
val trailingIndex = getTrailingParameterIndex(parameters)
if (trailingIndex < 1) {
logger.warn { "Received malformed $cmd. Returning unimplemented as fallback." }
return ServerMessage.UNIMPLEMENTED("$cmd ${parameters.joinToString(" ")}")
}
val targets = parameters.subList(0, trailingIndex - 1)
if (cmd == "NOTICE")
ServerMessage.Notice(targets, "")
else
ServerMessage.PrivMsg(targets, "")
}
else -> {
logger.warn { "$name left unparsed" }
ServerMessage.UNIMPLEMENTED("$name ${parameters.joinToString(" ")}")
}
}
}
}
class NumericCommand(val number: Int, val parameters: List<String>?) : Command {
override fun toServerMessage(): ServerMessage {
return ServerMessage.UNIMPLEMENTED("$number ${parameters?.joinToString(" ")}")
}
}
}
private var tags: String? = null private var tags: String? = null
private var prefix: String? = null private var prefix: String? = null
private lateinit var command: Command private lateinit var command: Command
abstract class Command(open val command: String, open val parameters: List<String>) {
abstract fun toServerMessage(): ServerMessage
fun getTrailingParameterIndex() = parameters.indexOfFirst { it.startsWith(":") }
fun getTrailingParameterString() =
parameters
.subList(getTrailingParameterIndex(), parameters.size)
.joinToString(" ")
.removePrefix(":")
fun getNonTrailingParameterString() =
parameters
.subList(0, getTrailingParameterIndex())
.joinToString(" ")
.removePrefix(":")
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(":"))
"MODE" -> ServerMessage.Mode(
parameters.first(),
parameters.last().removePrefix(":")
) // TODO MODE, CHANMODE, LOCALMODE
"NOTICE" -> ServerMessage.Notice(
getNonTrailingParameters(),
getTrailingParameterString()
)
"PRIVMSG" -> ServerMessage.PrivMsg(
getNonTrailingParameters(),
getTrailingParameterString()
)
else -> {
ServerMessage.UNIMPLEMENTED("$command ${parameters.joinToString(" ")}")
}
}
}
class NumericCommand(override val command: String, override val parameters: List<String>) : Command(command, parameters) {
override fun toServerMessage() = when (command.toInt()) {
5 -> ServerMessage.Numeric.RPL_ISUPPORT(getNonTrailingParameterString())
321 -> ServerMessage.Numeric.RPL_LISTSTART()
322 -> ServerMessage.Numeric.RPL_LIST(
parameters[1],
parameters[2].toInt(),
getTrailingParameterString()
)
323 -> ServerMessage.Numeric.RPL_LISTEND()
else -> ServerMessage.UNIMPLEMENTED(parameters.toString())
}
}
}
/** /**
* Messages have this format, as rough ABNF: * Messages have this format, as rough ABNF:
* *
@@ -87,15 +90,30 @@ class MessageParser(val incoming: Channel<String>) {
* *
* parameters: If it exists, data relevant to this specific command. * parameters: If it exists, data relevant to this specific command.
* *
* ignoring tags for now ;-;.. still making space for them * ===================
*
* 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
*/ */
fun build(commandList: List<String>): ServerMessage { fun build(commandList: List<String>): ServerMessage {
val tokens = commandList.toMutableList() val tokens = commandList.toMutableList()
tags = if (tokens.first().startsWith("@")) tokens.removeFirst() else null
prefix = if (tokens.first().startsWith(":")) tokens.removeFirst() else null // remove tags from strlist if present
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
// get command e.g. PING
val commandStr = tokens.removeFirst() val commandStr = tokens.removeFirst()
command = if (commandStr.toIntOrNull() != null) Command.NumericCommand(commandStr.toInt(), tokens)
else Command.NamedCommand(commandStr, tokens) // Check if command is a numeric (RPL/ERR) or named
command = if (commandStr.toIntOrNull() == null) Command.NamedCommand(commandStr, tokens)
else Command.NumericCommand(commandStr, tokens)
return command.toServerMessage() return command.toServerMessage()
} }
@@ -104,8 +122,11 @@ class MessageParser(val incoming: Channel<String>) {
} }
suspend fun start() { suspend fun start() {
val builder = MessageBuilder()
for (msg in incoming) { for (msg in incoming) {
incomingParsedMessages.send(build(msg)) logger.trace { "Receiving: $msg" }
incomingParsedMessages.send(builder.build(msg))
} }
} }
} }
@@ -1,42 +1,53 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.irc.util.Helpers
import com.shr4pnel.ferretirc.net.messages.MessageIO 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.slf4j.logger
import io.ktor.network.sockets.Socket import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.openReadChannel 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.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.slf4j.helpers.NOPLogger
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.reflect.cast import kotlin.reflect.cast
class MessageReader(socket: Socket, scope: CoroutineScope) : MessageIO(socket, scope) { class MessageReader(socket: Socket, scope: CoroutineScope) : MessageIO(socket, scope) {
private lateinit var receive: ByteReadChannel private lateinit var receive: ByteReadChannel
private val listenedMessages = mutableListOf<ServerMessage>() private val messageBuffer = MutableSharedFlow<ServerMessage>(16, 64)
val messageBuffer = MutableSharedFlow<ServerMessage>(16, 64) val sharedMessageBuffer = messageBuffer.asSharedFlow()
val parser = MessageParser(incomingMessages) val parser = MessageParser(incomingMessages)
private val logger = if (Helpers.enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
init { init {
scope.launch { scope.launch {
parser.start() parser.start()
} }
scope.launch { scope.launch {
for (msg in parser.incomingParsedMessages) messageBuffer.emit(msg) for (msg in parser.incomingParsedMessages)
messageBuffer.emit(msg)
} }
} }
override fun start() = scope.launch { override fun start() {
scope.launch {
receive = socket.openReadChannel() receive = socket.openReadChannel()
while (true) { while (true) {
val line = receive.readLineStrict() ?: break // >:( no LineEnding option for just CRLF? charlatans... val line = receive.readLineStrict() ?: break // >:( no LineEnding option for just CRLF? charlatans...
logger.debug { "Receive: $line" }
incomingMessages.send(line) incomingMessages.send(line)
} }
} }
}
suspend fun <T : ServerMessage> waitForNext(kClass: KClass<T>, predicate: (T) -> Boolean = { true }): T { suspend fun <T : ServerMessage> waitForNext(kClass: KClass<T>, predicate: (T) -> Boolean = { true }): T {
return messageBuffer return messageBuffer
@@ -1,17 +1,20 @@
package com.shr4pnel.ferretirc.net package com.shr4pnel.ferretirc.net
import com.shr4pnel.ferretirc.irc.util.Helpers
import com.shr4pnel.ferretirc.net.messages.MessageIO import com.shr4pnel.ferretirc.net.messages.MessageIO
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.slf4j.logger
import io.ktor.network.sockets.Socket import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.openWriteChannel 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.launch import kotlinx.coroutines.launch
import org.slf4j.helpers.NOPLogger
class MessageWriter(socket: Socket, scope: CoroutineScope) : MessageIO(socket, scope) { class MessageWriter(socket: Socket, scope: CoroutineScope) : MessageIO(socket, scope) {
private lateinit var send: ByteWriteChannel private lateinit var send: ByteWriteChannel
private val logger = KotlinLogging.logger("MessageWriter") private val logger = if (Helpers.enableLogging) KotlinLogging.logger {} else KotlinLogging.logger(NOPLogger.NOP_LOGGER)
override fun start() = scope.launch { override fun start() = scope.launch {
send = socket.openWriteChannel() send = socket.openWriteChannel()
@@ -1,17 +1,22 @@
package com.shr4pnel.ferretirc.net.messages package com.shr4pnel.ferretirc.net.messages
import io.ktor.network.selector.SelectorManager import com.shr4pnel.ferretirc.irc.IRCChannel
import kotlinx.coroutines.Dispatchers
/** /**
* IRC Messages sent by the client * IRC Messages sent by the client
*/ */
sealed class ClientMessage(val strName: String) { sealed class ClientMessage(val strName: String) {
abstract fun toWireIntermediate(): String
fun toWire() = "${toWireIntermediate()}\r\n".encodeToByteArray() fun toWire() = "${toWireIntermediate()}\r\n".encodeToByteArray()
companion object Util { protected open val params = emptyList<String>()
val selectorManager = SelectorManager(Dispatchers.IO) protected open val trailing: String? = null
fun toWireIntermediate(): String {
return buildString {
append(strName)
params.forEach { append(" $it") }
trailing?.let { append(" :$it") }
}
} }
class Ping(val token: String? = null) : ClientMessage("PING") { class Ping(val token: String? = null) : ClientMessage("PING") {
@@ -21,33 +26,41 @@ sealed class ClientMessage(val strName: String) {
} }
} }
override fun toWireIntermediate() = "$strName ${token.orEmpty()}" override val params: kotlin.collections.List<String>
get() = listOfNotNull(token)
} }
sealed class Cap : ClientMessage("CAP") { sealed class Cap : ClientMessage("CAP") {
val validSubcommands = listOf("LS", "LIST", "REQ", "END") class LS(val version: Int = 302) : Cap() {
override val params: kotlin.collections.List<String>
override fun toWireIntermediate() = when (this) { get() = listOf("LS", version.toString())
is LS -> "CAP LS $version"
is REQ -> TODO()
is END -> "CAP END"
is LIST -> TODO()
} }
class LS(val version: Int = 302) : Cap() class LIST : Cap() {
class LIST : Cap() override val params: kotlin.collections.List<String>
class REQ(val version: Int?) : Cap() get() = TODO("IMPLEMENT CAP LIST")
class END : Cap() }
class REQ(val version: Int?) : Cap() {
override val params: kotlin.collections.List<String>
get() = TODO("IMPL CAP REQ")
}
class END : Cap() {
override val params: kotlin.collections.List<String>
get() = listOf("END")
}
} }
class Nick(val nickname: String) : ClientMessage("NICK") { class Nick(val nickname: String) : ClientMessage("NICK") {
override fun toWireIntermediate() = "NICK $nickname"
init { init {
require(nickname.length < 10) { "\"$nickname\" exceeds IRCs maximum nickname length of 9" } require(nickname.length < 10) { "\"$nickname\" exceeds IRCs maximum nickname length of 9" }
require(!nickname.startsWith(":") && !nickname.startsWith("#")) { "\"$nickname\" may not begin with : or #" } // TODO THIS SHOULD BLACKLIST ALL PREFIXES NAMED IN CHANTYPES PARAMETER require(!nickname.startsWith(":") && !nickname.startsWith("#")) { "\"$nickname\" may not begin with : or #" } // TODO THIS SHOULD BLACKLIST ALL PREFIXES NAMED IN CHANTYPES PARAMETER
require(!nickname.contains(" ")) { "$nickname may not contain a space" } require(!nickname.contains(" ")) { "$nickname may not contain a space" }
} }
override val params: kotlin.collections.List<String>
get() = listOf(nickname)
} }
class User(val username: String, val realName: String) : ClientMessage("USER") { class User(val username: String, val realName: String) : ClientMessage("USER") {
@@ -56,27 +69,37 @@ sealed class ClientMessage(val strName: String) {
require(username.isNotEmpty()) { "Username may not be blank" } require(username.isNotEmpty()) { "Username may not be blank" }
} }
override fun toWireIntermediate() = "USER $username 0 * :$realName" override val params: kotlin.collections.List<String>
get() = listOf(username, "0", "*")
override val trailing: String
get() = realName
} }
class Pass(val password: String) : ClientMessage("PASS") { class Pass(val password: String) : ClientMessage("PASS") {
override fun toWireIntermediate() = "PASS $password" override val params: kotlin.collections.List<String>
get() = listOf(password)
} }
class Oper(val name: String, val password: String) : ClientMessage("OPER") { class Oper(val name: String, val password: String) : ClientMessage("OPER") {
override fun toWireIntermediate() = "OPER $name $password" override val params: kotlin.collections.List<String>
get() = listOf(name, password)
} }
class Die(): ClientMessage("DIE") { class Die : ClientMessage("DIE")
override fun toWireIntermediate() = "DIE"
class List : ClientMessage("LIST")
class Join(val channel: IRCChannel) : ClientMessage("JOIN") {
override val params: kotlin.collections.List<String>
get() = listOf(channel.chanName)
} }
class List(): ClientMessage("LIST") { class PrivMsg(val target: IRCChannel, val message: String) : ClientMessage("PRIVMSG") {
override fun toWireIntermediate() = "LIST" override val params: kotlin.collections.List<String>
} get() = listOf(target.chanName) // TODO RPL_ISUPPORT REQUIREMENTS IN FUN EG LINELEN STATUSMSG - ALSO ADD FUCKING STUPID STATUSMSG & SUPPORT FOR MASKS. FUCK ME
class UNIMPLEMENTED() : ClientMessage("") { override val trailing: String
override fun toWireIntermediate() = "" get() = message
} }
} }
@@ -2,11 +2,10 @@ package com.shr4pnel.ferretirc.net.messages
import io.ktor.network.sockets.Socket import io.ktor.network.sockets.Socket
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
abstract class MessageIO(val socket: Socket, val scope: CoroutineScope) { abstract class MessageIO(val socket: Socket, val scope: CoroutineScope) {
val incomingMessages = Channel<String>(Channel.BUFFERED) val incomingMessages = Channel<String>(Channel.BUFFERED)
val outgoingMessages = Channel<ClientMessage>(Channel.BUFFERED) val outgoingMessages = Channel<ClientMessage>(Channel.BUFFERED)
abstract fun start(): Job abstract fun start(): Any
} }
@@ -1,10 +1,20 @@
package com.shr4pnel.ferretirc.net.messages package com.shr4pnel.ferretirc.net.messages
/**
* Representation of responses from the IRC server
*/
sealed class ServerMessage { sealed class ServerMessage {
class Cap() : ServerMessage() data class Cap(val placeholder: String? = null) : ServerMessage()
class Pong(val token: String? = null) : ServerMessage() data class Pong(val token: String? = null) : ServerMessage()
class Mode(val operatorName: String, val pMask: String): ServerMessage() data class Mode(val operatorName: String, val pMask: String) : ServerMessage()
class PrivMsg(val targets: List<String>, message: String): ServerMessage() data class PrivMsg(val targets: List<String>, val message: String) : ServerMessage()
class Notice(val targets: List<String>, message: String): ServerMessage() data class Notice(val targets: List<String>, val message: String) : ServerMessage()
class UNIMPLEMENTED(val msg: String) : ServerMessage() data class UNIMPLEMENTED(val msg: 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_LIST(val chanName: String, val clientCount: Int, val topic: String) : Numeric(322)
class RPL_LISTEND : Numeric(323)
}
} }
+1 -1
View File
@@ -6,7 +6,7 @@
<import class="ch.qos.logback.core.ConsoleAppender"/> <import class="ch.qos.logback.core.ConsoleAppender"/>
<appender name="STDOUT" class="ConsoleAppender"> <appender name="STDOUT" class="ConsoleAppender">
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<pattern>%d{mm:ss.SSS} [%-5level] %logger{36}: %msg%n</pattern> <pattern>%d{ISO8601} [%5level] %.36logger %msg%n</pattern>
</encoder> </encoder>
</appender> </appender>
<root level="debug"> <root level="debug">
@@ -2,7 +2,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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -17,14 +16,16 @@ import kotlin.test.assertNotNull
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class IrcClientTest { class IrcClientTest {
private val logger = KotlinLogging.logger {}
companion object { companion object {
lateinit var processes: MutableList<Process> lateinit var processes: MutableList<Process>
val client = IrcClient("localhost", 6667) val client = IrcClient("localhost", 6667)
private val logger = KotlinLogging.logger("IrcClientTest") private val logger = KotlinLogging.logger {}
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll
fun setup() { fun setup() = runBlocking {
val logFile = File("src/test/resources/logs/ngircd.log") val logFile = File("src/test/resources/logs/ngircd.log")
val ngircdConfigPath = javaClass.classLoader.getResource("ngircd.conf")!!.path val ngircdConfigPath = javaClass.classLoader.getResource("ngircd.conf")!!.path
logFile.createNewFile() logFile.createNewFile()
@@ -38,13 +39,8 @@ class IrcClientTest {
ProcessBuilder("ts", "-s", "%H:%M:%.S").redirectOutput(logFile) ProcessBuilder("ts", "-s", "%H:%M:%.S").redirectOutput(logFile)
) )
) )
client.scope.launch {
client.connect() client.connect()
client.register("shr4p", "password") client.register("shr4p", "Tyler D", "password")
}
runBlocking {
delay(0.5.seconds)
}
} }
@JvmStatic @JvmStatic
@@ -93,7 +89,23 @@ class IrcClientTest {
@Test @Test
fun listChannels() = runBlocking { fun listChannels() = runBlocking {
client.queueMessage(Message.List()) val channels = client.server.fetchChannels()
delay(1.seconds) assert(channels.isNotEmpty())
client.queueMessage(Message.Join(channels.first()))
}
@Test
fun messageLands() = runBlocking {
val guestUser = IrcClient("localhost", 6667, false)
guestUser.connect()
guestUser.register("guest", "guest", "pass")
val channels = client.server.fetchChannels()
val message = Message.PrivMsg(channels.first(), "bring me to life")
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())
guestUser.close()
} }
} }
+5
View File
@@ -57,3 +57,8 @@
[OPERATOR] [OPERATOR]
Name = shr4p Name = shr4p
Password = password Password = password
[CHANNEL]
Name = #Default
Topic = Awesome fukin channel topic
Modes = P