Initial commit

This commit is contained in:
2026-08-06 01:03:27 +01:00
commit aa74300efb
20 changed files with 685 additions and 0 deletions
@@ -0,0 +1,39 @@
package com.shr4pnel.ferretirc
import com.shr4pnel.ferretirc.net.ClientMessage
import com.shr4pnel.ferretirc.net.Connection
import com.shr4pnel.ferretirc.net.MessageReader
import com.shr4pnel.ferretirc.net.MessageWriter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.net.URL
class IrcClient(val nick: String, val sock: URL) {
val port: Int = sock.port
val hostname: String = sock.host
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val connection = Connection(hostname, port, scope)
lateinit var messages: Channel<String>
init {
require(port != -1, fun() = "A port must be specified in your argument to IRCClient")
require(hostname.isNotEmpty(), fun() = "A port must be specified in your argument to IRCClient")
}
suspend fun connect() {
connection.connect()
messages = connection.reader.incomingMessages
}
fun queueMessage(message: ClientMessage) = scope.launch {
connection.writer.outgoingMessages.send(message)
}
fun close() {
scope.cancel()
}
}
@@ -0,0 +1,64 @@
package com.shr4pnel.ferretirc.net
import io.ktor.network.selector.SelectorManager
import kotlinx.coroutines.Dispatchers
/**
* IRC Messages sent by the client
*/
sealed class ClientMessage(val strName: String) {
abstract fun toWire(): ByteArray
companion object Util {
val selectorManager = SelectorManager(Dispatchers.IO)
}
class Ping(val token: String?) : ClientMessage("PING") {
init {
require(token?.isNotEmpty() ?: true) {
"Token to PING should be null or a non-empty string"
}
}
override fun toWire() = "$strName $token\r\n".encodeToByteArray()
}
sealed class Cap : ClientMessage("CAP") {
val validSubcommands = listOf("LS", "LIST", "REQ", "END")
override fun toWire() = when (this) {
is LS -> "CAP LS $version\r\n".encodeToByteArray()
is REQ -> TODO()
}
class LS(val version: Int = 302) : Cap() {
val subcommand = "LS"
}
class REQ(val version: Int?) : Cap() {
val subcommand = "REQ"
}
class END {
val subcommand = "END"
}
// init {
// require(validSubcommands.contains(subcommand.toUpperCasePreservingASCIIRules())) {
// "$subcommand is not a valid subcommand to CAP"
// }
// }
}
class Nick(val nickname: String) : ClientMessage("NICK") {
override fun toWire() = "NICK $nickname\r\n".encodeToByteArray()
init {
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.contains(" ")) { "$nickname may not contain a space" }
}
}
}
@@ -0,0 +1,20 @@
package com.shr4pnel.ferretirc.net
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import kotlinx.coroutines.CoroutineScope
class Connection(val hostname: String, val port: Int, val scope: CoroutineScope) {
private val socketBuilder = aSocket(ClientMessage.selectorManager).tcp()
lateinit var socket: Socket
lateinit var reader: MessageReader
lateinit var writer: MessageWriter
suspend fun connect() {
socket = socketBuilder.connect(hostname, port)
reader = MessageReader(socket, scope)
writer = MessageWriter(socket, scope)
reader.start()
writer.start()
}
}
@@ -0,0 +1,12 @@
package com.shr4pnel.ferretirc.net
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 suspend fun start()
}
@@ -0,0 +1,22 @@
package com.shr4pnel.ferretirc.net
import io.ktor.network.sockets.Socket
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.launch
class MessageReader(socket: Socket, scope: CoroutineScope): MessageIO(socket, scope) {
private lateinit var receive: ByteReadChannel
override suspend fun start() {
receive = socket.openReadChannel()
scope.launch {
while (true) {
val line = receive.readLineStrict() ?: break // >:( no LineEnding option for just CRLF? charlatans...
incomingMessages.send(line)
}
}
}
}
@@ -0,0 +1,22 @@
package com.shr4pnel.ferretirc.net
import io.ktor.network.sockets.Socket
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.launch
class MessageWriter(socket: Socket, scope: CoroutineScope): MessageIO(socket, scope) {
private lateinit var send: ByteWriteChannel
override suspend fun start() {
send = socket.openWriteChannel()
scope.launch {
for (msg in outgoingMessages) {
send.writeFully(msg.toWire())
send.flush()
}
}
}
}