Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5df85bf2
|
||
|
|
d5397f99a3
|
||
|
|
3bf9d83450
|
||
|
|
207c460b85
|
||
|
|
59c96e48be
|
||
|
|
973a6fa11b
|
||
|
|
ab88896b01
|
||
|
|
7384642a6d
|
||
|
|
e0194d5c0a
|
||
|
|
d52b1085f9
|
||
|
|
3ad2740dd2
|
||
|
|
398fe1ef4e
|
||
|
|
d8e89775f5
|
+5
-5
@@ -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 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")
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,3 +57,8 @@
|
|||||||
[OPERATOR]
|
[OPERATOR]
|
||||||
Name = shr4p
|
Name = shr4p
|
||||||
Password = password
|
Password = password
|
||||||
|
|
||||||
|
[CHANNEL]
|
||||||
|
Name = #Default
|
||||||
|
Topic = Awesome fukin channel topic
|
||||||
|
Modes = P
|
||||||
Reference in New Issue
Block a user