Initial commit

This commit is contained in:
weishaupt
2026-09-01 12:32:15 -04:00
commit defdefdb28
33 changed files with 3540 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
package irc
import data.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.io.File
import java.util.concurrent.ConcurrentHashMap
// Per-buffer message history cap. Named so it's a one-line change to temporarily lower
// for testing what happens once a busy channel's buffer actually hits the cap (auto-scroll
// keying on the wrong thing is invisible in a quiet test channel that never reaches this).
private const val MESSAGE_HISTORY_CAP = 500
// How long to wait after a message before actually writing that buffer's history to disk,
// coalescing any further messages that arrive in the meantime into the same write. See
// scheduleSave() - previously every single message triggered its own full save, which is
// a lot of disk I/O to keep up with in a busy channel and was the likely direct cause of
// reported slowness under real message volume.
private const val SAVE_DEBOUNCE_MS = 2500L
/**
* App-wide singleton. Owns every IrcConnection and every ChannelBuffer, so that all
* windows read from the same source of truth via StateFlow.
*/
object ConnectionManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val connections = ConcurrentHashMap<String, IrcConnection>()
private val ownNicks = ConcurrentHashMap<String, String>() // serverId -> our current nick
private var historyRepo: MessageHistoryRepository? = null
private val _buffers = MutableStateFlow<Map<BufferKey, ChannelBuffer>>(emptyMap())
val buffers: StateFlow<Map<BufferKey, ChannelBuffer>> = _buffers
private val _statuses = MutableStateFlow<Map<String, String>>(emptyMap())
val statuses: StateFlow<Map<String, String>> = _statuses
private val _ownNicks = MutableStateFlow<Map<String, String>>(emptyMap())
val ownNicksFlow: StateFlow<Map<String, String>> = _ownNicks
fun init(rootDir: File) {
historyRepo = MessageHistoryRepository(rootDir)
}
private fun setOwnNick(serverId: String, nick: String) {
ownNicks[serverId] = nick
_ownNicks.update { it + (serverId to nick) }
}
// Fires once for every brand-new private-message buffer (a query with someone
// who wasn't already an open window). Main.kt listens to this to auto-open a
// window for it, same as clicking a channel manually.
private val _newQueryBuffers = MutableSharedFlow<BufferKey>(extraBufferCapacity = 16)
val newQueryBuffers: SharedFlow<BufferKey> = _newQueryBuffers
fun connect(config: ServerConfig) {
// TEMP DEBUG (see task: on-connect identify command investigation) - confirms
// whether the config this connect() call actually received has the on-connect
// commands the caller thinks it saved.
println("[connmgr-debug] connect(${config.id}) called - onConnectCommands=${config.onConnectCommands}")
val existing = connections[config.id]
if (existing != null) {
// This used to just `return` here, making connect() a permanent no-op for the
// rest of the process once a server had been connected once - editing that
// server's config afterward (identify commands, autojoin, etc.) and re-saving
// had zero effect until a full app restart, because the running IrcConnection's
// `config` was captured once at construction and never replaced. Tearing down
// the old connection and replacing it means a connect() call always actually
// uses whatever was most recently saved.
println("[connmgr-debug] connect(${config.id}) - already connected, replacing with a fresh connection using the latest config")
existing.disconnect()
}
setOwnNick(config.id, config.nick)
val conn = IrcConnection(
config = config,
scope = scope,
onLine = { line -> handleLine(config.id, line) },
onStatus = { msg -> _statuses.update { it + (config.id to msg) } }
)
connections[config.id] = conn
conn.connect()
}
fun disconnectAll() {
connections.values.forEach { it.disconnect() }
connections.clear()
flushAllSaves()
}
fun connectionFor(serverId: String): IrcConnection? = connections[serverId]
// IRC servers don't echo PRIVMSG back to its sender, so a sent message has to be
// added to our own buffer locally - otherwise it would never show up in the window
// that sent it.
fun sendChannelMessage(serverId: String, target: String, text: String) {
connectionFor(serverId)?.sendMessage(target, text)
val myNick = ownNicks[serverId] ?: return
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text)
)
}
// Same local-echo problem as sendChannelMessage, but for /me: the wire form is a
// CTCP ACTION wrapper, while the buffer should just hold the plain action text
// (ChannelScreen renders MessageKind.ACTION with its own "* nick ..." prefix).
fun sendChannelAction(serverId: String, target: String, action: String) {
connectionFor(serverId)?.sendMessage(target, "ACTION $action")
val myNick = ownNicks[serverId] ?: return
addMessage(
BufferKey(serverId, target),
IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action)
)
}
// Raw passthrough for slash commands that don't have dedicated handling.
fun sendRaw(serverId: String, raw: String) {
connectionFor(serverId)?.send(raw)
}
// Local-only feedback (e.g. the /reload confirmation) that never touches the
// wire - just appended to the buffer the same way any other SYSTEM line is.
fun addSystemMessage(serverId: String, target: String, text: String) {
addMessage(BufferKey(serverId, target), IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text))
}
// One mutex per buffer key, guarding the "is this buffer new -> seed its history from
// disk if so -> append the new message" sequence for that key end to end. Previously
// that sequence was: a synchronous isNewBuffer read, then a separate async coroutine
// that did the disk seed-load and the in-memory update - two steps with a real window
// between them. In a busy channel, several messages for a brand-new buffer (e.g. right
// after JOIN, when NAMES/TOPIC/a flood of chat lines all land within milliseconds) could
// each see isNewBuffer=true before any of them had finished writing, each independently
// re-reading the same stale on-disk snapshot and racing to write - concretely wasteful
// (redundant disk reads) and, since MessageHistoryRepository.saveMessages() does a plain
// unsynchronized file.writeText() with no ordering guarantee between concurrent callers,
// capable of persisting a stale/incomplete snapshot last even when the in-memory state
// was already fully correct. A per-key Mutex makes the whole check-seed-append sequence
// for a given key strictly one-at-a-time, so only the genuinely first caller ever seeds
// from disk and no two writers for the same key can be in that window simultaneously.
private val bufferMutexes = ConcurrentHashMap<BufferKey, Mutex>()
private fun mutexFor(key: BufferKey): Mutex = bufferMutexes.computeIfAbsent(key) { Mutex() }
// Pending debounced saves, keyed by buffer - see scheduleSave().
private val pendingSaveJobs = ConcurrentHashMap<BufferKey, Job>()
private fun addMessage(key: BufferKey, msg: IrcMessage) {
// Do the file I/O (seeding a brand-new buffer from disk, then persisting the
// updated list) off the caller's thread - addMessage gets called both from the
// IO-dispatched read loop and directly from UI-thread local-echo sends.
scope.launch(Dispatchers.IO) {
mutexFor(key).withLock {
val isNewBuffer = !_buffers.value.containsKey(key)
val seeded = if (isNewBuffer) (historyRepo?.loadMessages(key) ?: emptyList()) else emptyList()
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key, messages = seeded)
val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP)
map + (key to existing.copy(messages = trimmed))
}
if (isNewBuffer && !isChannelName(key.name)) {
_newQueryBuffers.emit(key)
}
}
}
scheduleSave(key)
}
// Coalesces rapid-fire saves for the same buffer into at most one write every
// SAVE_DEBOUNCE_MS, instead of a full history save (up to 500 messages) on every single
// incoming message - the direct cause of reported slowness in a busy channel. Reads
// _buffers.value fresh at write time (not at schedule time), so whichever message
// triggers the eventual write, it always persists the latest state, not a stale
// snapshot from whenever it happened to be scheduled. computeIfAbsent is atomic, so
// concurrent callers for the same key can only ever schedule one pending job.
private fun scheduleSave(key: BufferKey) {
pendingSaveJobs.computeIfAbsent(key) {
scope.launch(Dispatchers.IO) {
delay(SAVE_DEBOUNCE_MS)
pendingSaveJobs.remove(key)
val toSave = _buffers.value[key]?.messages ?: emptyList()
historyRepo?.saveMessages(key, toSave)
}
}
}
// Bypasses the debounce and writes every buffer's current state immediately - called on
// shutdown so the last few seconds of a debounce window are never silently lost.
private fun flushAllSaves() {
pendingSaveJobs.values.forEach { it.cancel() }
pendingSaveJobs.clear()
_buffers.value.forEach { (key, buffer) -> historyRepo?.saveMessages(key, buffer.messages) }
}
private fun updateUsers(key: BufferKey, transform: (List<ChannelUser>) -> List<ChannelUser>) {
_buffers.update { map ->
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(users = transform(existing.users)))
}
}
private fun handleLine(serverId: String, line: ParsedLine) {
val now = System.currentTimeMillis()
val nick = nickFromPrefix(line.prefix) ?: line.prefix
when (line.command) {
"PRIVMSG" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
val isAction = text.startsWith("ACTION") && text.endsWith("")
val body = if (isAction) text.removePrefix("ACTION ").removeSuffix("") else text
// Channel messages are keyed by the channel name. Private messages arrive
// with target == our own nick, so key those by the *sender* instead -
// that's the conversation identity a query window should use.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(
BufferKey(serverId, bufferName),
IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)
)
}
"JOIN" -> {
val channel = line.params.getOrNull(0) ?: return
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.JOIN, nick, "$nick joined $channel"))
if (nick != null) updateUsers(BufferKey(serverId, channel)) { it + ChannelUser(nick) }
}
"PART" -> {
val channel = line.params.getOrNull(0) ?: return
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.PART, nick, "$nick left $channel"))
if (nick != null) updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == nick } }
}
"KICK" -> {
val channel = line.params.getOrNull(0) ?: return
val kicked = line.params.getOrNull(1) ?: return
val reason = line.params.getOrNull(2) ?: ""
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.KICK, nick, "$kicked was kicked by $nick ($reason)", target = kicked)
)
updateUsers(BufferKey(serverId, channel)) { users -> users.filterNot { it.nick == kicked } }
}
"MODE" -> {
val channel = line.params.getOrNull(0) ?: return
if (!channel.startsWith("#")) return
val modeStr = line.params.getOrNull(1) ?: ""
val modeTarget = line.params.getOrNull(2)
addMessage(
BufferKey(serverId, channel),
IrcMessage(now, MessageKind.MODE, nick, "$nick sets mode $modeStr ${modeTarget ?: ""}".trim())
)
if (modeTarget != null && (modeStr == "+o" || modeStr == "-o" || modeStr == "+v" || modeStr == "-v")) {
updateUsers(BufferKey(serverId, channel)) { users ->
users.map { u ->
if (u.nick != modeTarget) u
else when (modeStr) {
"+o" -> u.copy(isOp = true)
"-o" -> u.copy(isOp = false)
"+v" -> u.copy(isVoice = true)
"-v" -> u.copy(isVoice = false)
else -> u
}
}
}
}
}
"TOPIC" -> {
val channel = line.params.getOrNull(0) ?: return
val topic = line.params.getOrNull(1) ?: ""
_buffers.update { map ->
val key = BufferKey(serverId, channel)
val existing = map[key] ?: ChannelBuffer(key)
map + (key to existing.copy(topic = topic))
}
addMessage(BufferKey(serverId, channel), IrcMessage(now, MessageKind.TOPIC, nick, "$nick changed topic: $topic"))
}
"353" -> { // NAMES reply
val channel = line.params.getOrNull(2) ?: return
val names = line.params.getOrNull(3)?.split(" ")?.filter { it.isNotBlank() } ?: return
val parsedUsers = names.map { raw ->
when {
raw.startsWith("@") -> ChannelUser(raw.removePrefix("@"), isOp = true)
raw.startsWith("+") -> ChannelUser(raw.removePrefix("+"), isVoice = true)
else -> ChannelUser(raw)
}
}
updateUsers(BufferKey(serverId, channel)) { existing -> (existing + parsedUsers).distinctBy { it.nick } }
}
"NOTICE" -> {
val target = line.params.getOrNull(0) ?: return
val text = line.params.getOrNull(1) ?: return
// Same fix PRIVMSG already had: a notice directed AT us arrives with
// target == our own nick, not the sender. This mattered a lot in practice -
// NickServ/ChanServ almost universally reply to commands (IDENTIFY, etc.)
// via NOTICE rather than PRIVMSG, so without this fallback every identify
// success/failure notice was being filed under a buffer keyed by our OWN
// nick - which no window ever opens for - instead of under "NickServ",
// making it look like the automatic identify produced no feedback at all,
// whether or not it actually worked.
val bufferName = if (isChannelName(target)) target else (nick ?: target)
addMessage(BufferKey(serverId, bufferName), IrcMessage(now, MessageKind.NOTICE, nick, text))
}
"NICK" -> {
val oldNick = nick ?: return
val newNick = line.params.getOrNull(0) ?: return
val affectedKeys = _buffers.value.filterKeys { it.serverId == serverId }
.filterValues { buf -> buf.users.any { it.nick == oldNick } }
.keys
_buffers.update { map ->
map.mapValues { (key, buf) ->
if (key.serverId == serverId && buf.users.any { it.nick == oldNick }) {
buf.copy(users = buf.users.map { u -> if (u.nick == oldNick) u.copy(nick = newNick) else u })
} else buf
}
}
affectedKeys.forEach { key ->
addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "$oldNick is now known as $newNick"))
}
if (ownNicks[serverId] == oldNick) {
setOwnNick(serverId, newNick)
}
}
"QUIT" -> {
val reason = line.params.getOrNull(0) ?: ""
// Remove nick from every channel buffer on this server it appears in.
_buffers.update { map ->
map.mapValues { (key, buf) ->
if (key.serverId == serverId && nick != null && buf.users.any { it.nick == nick }) {
buf.copy(users = buf.users.filterNot { it.nick == nick })
} else buf
}
}
}
}
}
}
+288
View File
@@ -0,0 +1,288 @@
package irc
import data.ServerConfig
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.Socket
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
// TEMP DEBUG (see task: closed-channel-doesn't-receive-messages investigation) - remove
// once the overnight rejoin issue is confirmed fixed. Prefixed so it's easy to grep out.
private fun ircDebug(serverId: String, msg: String) {
println("[irc-debug][$serverId] $msg")
}
// Extra buffer after end-of-MOTD (376/422) before actually sending on-connect commands -
// see runPostRegistration(). Some networks' services genuinely aren't ready the same
// instant MOTD ends; this is cheap insurance on top of that already-real trigger.
private const val POST_MOTD_IDENTIFY_DELAY_MS = 1500L
// on-connect commands are meant to be raw IRC protocol lines (e.g.
// "PRIVMSG NickServ :IDENTIFY password"), but the app's own chat window teaches
// "/msg NickServ IDENTIFY password" as the correct thing to type (it IS correct there -
// ChannelWindow.kt's onSend translates that shorthand before sending) - an easy, natural
// mistake to reuse that exact phrasing in the on-connect-commands field, which has no such
// translation and just writes the string to the socket byte-for-byte. A real network
// confirmed this: the raw line got rejected outright with "421 Unknown command" before
// NickServ ever saw it, since IRC servers have no "/msg" command. This makes on-connect
// commands tolerant of that shorthand too, so either form works.
private fun translateOnConnectCommand(cmd: String): String {
val trimmed = cmd.trim()
if (!trimmed.startsWith("/msg ", ignoreCase = true)) return cmd
val rest = trimmed.substring(5).trim()
val spaceIdx = rest.indexOf(' ')
if (spaceIdx <= 0) return cmd
val target = rest.substring(0, spaceIdx)
val message = rest.substring(spaceIdx + 1).trim()
if (message.isEmpty()) return cmd
return "PRIVMSG $target :$message"
}
/**
* Owns one socket to one IRC server. Emits raw parsed lines via onLine.
* Handles registration (NICK/USER), and basic auto-reconnect with backoff.
*/
class IrcConnection(
val config: ServerConfig,
private val scope: CoroutineScope,
private val onLine: (ParsedLine) -> Unit,
private val onStatus: (String) -> Unit
) {
// @Volatile: read from the outbox-consumer coroutine, written from the read-loop
// coroutine - without it there's no cross-thread visibility guarantee, so the outbox
// consumer could keep observing a stale non-null `writer` from a socket that already
// closed (or a stale null after a new one connected), silently mis-routing sends.
@Volatile private var socket: Socket? = null
@Volatile private var writer: PrintWriter? = null
private var readJob: Job? = null
private var wantConnected = false
private var backoffMillis = 2000L
// Every channel we're currently in, whether from config.autoJoinChannels or joined
// manually mid-session - so a reconnect rejoins everything, not just the original
// auto-join list.
private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet<String>()
// Guards runPostRegistration() against running twice for the same connection cycle
// (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset
// to false at the top of every connect attempt so a reconnect re-arms it.
@Volatile private var registeredHandled = false
// Outgoing lines go through a single serial consumer instead of one
// scope.launch(Dispatchers.IO) per send() call. That old approach had two problems:
// (1) no ordering guarantee between independently-scheduled coroutines on a thread
// pool dispatcher, and (2) if the socket dropped between a send() call being made and
// its coroutine actually running, `writer` would already be null and the line was
// silently discarded with no trace - which made a dropped JOIN indistinguishable from
// one that was never requested. This queue preserves order and gives a single place
// to log what actually made it onto the wire vs. what got dropped.
private val outbox = Channel<String>(Channel.UNLIMITED)
init {
scope.launch(Dispatchers.IO) {
for (raw in outbox) {
val w = writer
if (w == null) {
// Kept (unlike the old per-line CHECKPOINT4 write-confirmation logging,
// removed - it fired on every single outbound line and was real
// console-I/O overhead under busy-channel volume, per println being
// synchronized/blocking) - this specific case should be rare and is
// worth knowing about if it ever happens.
ircDebug(config.id, "DROPPED (writer is null - not connected): \"$raw\"")
} else {
w.println(raw)
}
}
}
}
fun connect() {
wantConnected = true
readJob?.cancel()
readJob = scope.launch(Dispatchers.IO) {
while (wantConnected && isActive) {
var fallbackJob: Job? = null
try {
onStatus("Connecting to ${config.host}:${config.port}...")
ircDebug(config.id, "connecting to ${config.host}:${config.port} (tracked channels: $joinedChannels)")
val sock: Socket = if (config.useTls) {
val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket
val sslParams = sslSocket.sslParameters
sslParams.endpointIdentificationAlgorithm = "HTTPS"
sslSocket.sslParameters = sslParams
sslSocket.startHandshake()
sslSocket
} else {
Socket(config.host, config.port)
}
socket = sock
val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true)
writer = out
// Used to send "CAP REQ :sasl" here whenever config.saslUser was set, but
// there was never any AUTHENTICATE/CAP END follow-through - SASL isn't
// actually implemented (there's no UI to even set saslUser/saslPass). Per
// the IRCv3 CAP spec, a compliant server MUST hold off sending 001 until
// it sees CAP END, so starting a negotiation the client can never finish
// risked silently stalling registration forever on strict servers -
// meaning 001/376/422 (and therefore the on-connect identify command)
// would never fire at all. Removed until SASL is actually implemented
// end-to-end.
out.println("NICK ${config.nick}")
out.println("USER ${config.nick} 0 * :${config.nick}")
backoffMillis = 2000L
onStatus("Connected, registering...")
registeredHandled = false
// On-connect commands (identify, etc.) used to fire straight off 001 -
// technically "registered" but too early on plenty of real networks:
// NickServ/services in general aren't guaranteed ready for PRIVMSGs the
// instant 001 lands, which is exactly why real clients (mIRC, HexChat,
// irssi) fire "perform"/on-connect scripts off end-of-MOTD (376/422)
// instead - see runPostRegistration(). This matches that convention, with
// an 8s fallback in case a server never sends either (rare, but some
// minimal/bouncer-style endpoints omit MOTD numerics entirely).
fallbackJob = scope.launch {
delay(8000)
if (!registeredHandled) {
ircDebug(config.id, "no 376/422 within 8s of connecting - running perform+rejoin anyway (fallback)")
runPostRegistration()
}
}
val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8))
while (isActive) {
val line = reader.readLine() ?: break
handleRawLine(line)
}
} catch (e: Exception) {
onStatus("Connection error: ${e.message}")
ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels")
} finally {
fallbackJob?.cancel()
closeSocketQuietly()
}
if (wantConnected) {
onStatus("Reconnecting in ${backoffMillis / 1000}s...")
ircDebug(config.id, "reconnecting in ${backoffMillis}ms")
delay(backoffMillis)
backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L)
}
}
}
}
private fun handleRawLine(line: String) {
// Used to log every single raw line here (RECV: "...") - removed. It was the
// single highest-volume debug line in the app: one per incoming line, every line,
// forever, and println is synchronized/blocking - a real, measurable cost in a
// busy channel, not just noise. The specific 001/376/422 confirmations below are
// once-per-connect and stay.
if (line.startsWith("PING")) {
val token = line.substringAfter("PING ")
send("PONG $token")
return
}
val parsed = parseIrcLine(line) ?: return
// CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets
// handled right here so it never shows up in a channel/PM window - real
// clients answer these silently too.
if (parsed.command == "PRIVMSG") {
val text = parsed.params.getOrNull(1)
if (text != null && text.startsWith("") && text.endsWith("") && text.length > 1) {
val ctcpBody = text.removePrefix("").removeSuffix("")
val ctcpCommand = ctcpBody.substringBefore(' ').uppercase()
if (ctcpCommand == "VERSION") {
val replyTo = nickFromPrefix(parsed.prefix)
if (replyTo != null) {
send("NOTICE $replyTo :VERSION It's an irc client, fucko!")
}
return
}
// Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal -
// ACTION in particular still needs to reach ConnectionManager to render as /me.
}
}
if (parsed.command == "001") {
ircDebug(config.id, "registered (001) - awaiting end-of-MOTD (376/422) before perform+rejoin")
}
if (parsed.command == "376" || parsed.command == "422") {
runPostRegistration()
}
onLine(parsed)
}
// Runs the on-connect ("perform") commands - NickServ/ChanServ identify, etc. - then
// rejoins every channel we were actually in (the configured auto-join list plus
// anything joined manually mid-session, so a reconnect doesn't forget those). Fired
// off end-of-MOTD rather than bare 001 - see the comment in connect() for why - so
// identify commands land once the session is actually settled enough for services to
// reliably act on them.
private fun runPostRegistration() {
if (registeredHandled) return
registeredHandled = true
// The whole sequence (identify, then rejoin) runs inside this delayed coroutine
// rather than just delaying the identify send - keeping both here preserves "run
// identify before joining anything that might require it" (rejoin used to run
// synchronously right after, which would otherwise now race ahead of a delayed
// identify send and reach the wire first). The delay itself is a few extra hundred
// ms of insurance on top of an already-real event (376/422) rather than a fixed
// guess from connection time - some networks' NickServ genuinely isn't ready to
// process a command in the same instant MOTD ends.
scope.launch(Dispatchers.IO) {
delay(POST_MOTD_IDENTIFY_DELAY_MS)
config.onConnectCommands.forEach { cmd ->
if (cmd.isNotBlank()) send(translateOnConnectCommand(cmd))
}
val rejoinList = (config.autoJoinChannels + joinedChannels).distinct()
ircDebug(config.id, "post-registration - rejoining: $rejoinList")
rejoinList.forEach { ch -> joinChannel(ch) }
}
}
fun send(raw: String) {
outbox.trySend(raw)
}
fun sendMessage(target: String, text: String) {
send("PRIVMSG $target :$text")
}
fun joinChannel(channel: String) {
joinedChannels.add(channel)
ircDebug(config.id, "joinChannel($channel) - tracked set now: $joinedChannels")
send("JOIN $channel")
}
fun partChannel(channel: String, reason: String = "") {
joinedChannels.remove(channel)
ircDebug(config.id, "partChannel($channel) - tracked set now: $joinedChannels")
send("PART $channel :$reason")
}
fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason")
fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick")
fun ban(channel: String, mask: String) = send("MODE $channel +b $mask")
fun disconnect() {
wantConnected = false
scope.launch(Dispatchers.IO) {
try { writer?.println("QUIT :leaving") } catch (_: Exception) {}
readJob?.cancel()
closeSocketQuietly()
}
}
private fun closeSocketQuietly() {
try { socket?.close() } catch (_: Exception) {}
socket = null
writer = null
}
}
+47
View File
@@ -0,0 +1,47 @@
package irc
/** A minimally-parsed raw IRC protocol line. */
data class ParsedLine(
val prefix: String?, // e.g. "nick!user@host" or a server name
val command: String, // e.g. "PRIVMSG", "JOIN", "376", etc.
val params: List<String> // trailing param (after ':') is params.last() if present
)
fun parseIrcLine(line: String): ParsedLine? {
if (line.isBlank()) return null
var rest = line
var prefix: String? = null
if (rest.startsWith(":")) {
val spaceIdx = rest.indexOf(' ')
if (spaceIdx == -1) return null
prefix = rest.substring(1, spaceIdx)
rest = rest.substring(spaceIdx + 1)
}
val trailingSplit = rest.indexOf(" :")
val paramsStr: String
val trailing: String?
if (trailingSplit != -1) {
paramsStr = rest.substring(0, trailingSplit)
trailing = rest.substring(trailingSplit + 2)
} else {
paramsStr = rest
trailing = null
}
val parts = paramsStr.split(" ").filter { it.isNotEmpty() }
if (parts.isEmpty()) return null
val command = parts[0].uppercase()
val params = parts.drop(1).toMutableList()
if (trailing != null) params.add(trailing)
return ParsedLine(prefix, command, params)
}
/** Extracts just the nick portion from an IRC prefix like "nick!user@host". */
fun nickFromPrefix(prefix: String?): String? {
if (prefix == null) return null
val bangIdx = prefix.indexOf('!')
return if (bangIdx != -1) prefix.substring(0, bangIdx) else prefix
}
+165
View File
@@ -0,0 +1,165 @@
package irc
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
// Standard 16-color mIRC palette (index 0-15).
val mircPalette = listOf(
Color(0xFFFFFFFF), // 0 white
Color(0xFF000000), // 1 black
Color(0xFF00007F), // 2 blue (navy)
Color(0xFF009300), // 3 green
Color(0xFFFF0000), // 4 red
Color(0xFF7F0000), // 5 brown (maroon)
Color(0xFF9C009C), // 6 purple
Color(0xFFFC7F00), // 7 orange
Color(0xFFFFFF00), // 8 yellow
Color(0xFF00FC00), // 9 light green
Color(0xFF00939C), // 10 cyan
Color(0xFF00FFFF), // 11 light cyan
Color(0xFF0000FC), // 12 light blue
Color(0xFFFF00FF), // 13 pink
Color(0xFF7F7F7F), // 14 grey
Color(0xFFD2D2D2) // 15 light grey
)
private const val BOLD = ''
private const val COLOR = ''
private const val RESET = ''
private const val REVERSE = ''
private const val UNDERLINE = ''
private const val ITALIC = ''
private val urlRegex = Regex("""https?://\S+""")
private val urlColor = Color(0xFF4EA1FF)
private val imageUrlRegex = Regex("""https?://[^\s]+""")
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
/**
* Extracts URLs from raw message text that look like they point at an image,
* based on the file extension in the path (ignoring query string/fragment).
*/
fun extractImageUrls(text: String): List<String> {
return imageUrlRegex.findAll(text).map { it.value }.filter { url ->
val cleanUrl = url.substringBefore('?').substringBefore('#')
val ext = cleanUrl.substringAfterLast('.', "").lowercase()
ext in imageExtensions
}.toList()
}
/**
* Parses a raw IRC line (which may contain mIRC control codes) into an AnnotatedString
* ready to render in Compose. baseColor is applied where no explicit foreground is set,
* so it follows the user's chosen black/white text setting.
*/
fun parseMircToAnnotatedString(raw: String, baseColor: Color): AnnotatedString {
val builder = AnnotatedString.Builder()
var bold = false
var underline = false
var italic = false
var reverse = false
var fg: Color? = null
var bg: Color? = null
fun currentStyle(): SpanStyle {
val effectiveFg = if (reverse) (bg ?: baseColor) else (fg ?: baseColor)
val effectiveBg = if (reverse) (fg ?: baseColor) else bg
return SpanStyle(
color = effectiveFg,
background = effectiveBg ?: Color.Unspecified,
fontWeight = if (bold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
textDecoration = if (underline) TextDecoration.Underline else TextDecoration.None
)
}
var i = 0
var runStart = 0
fun flush(endExclusive: Int) {
if (endExclusive > runStart) {
builder.withStyle(currentStyle()) {
append(raw.substring(runStart, endExclusive))
}
}
}
fun readColorDigits(startIdx: Int): Pair<Int?, Int> {
// Reads up to 2 digits starting at startIdx, returns (colorIndexOrNull, nextIndex)
var idx = startIdx
var numStr = ""
while (idx < raw.length && raw[idx].isDigit() && numStr.length < 2) {
numStr += raw[idx]
idx++
}
if (numStr.isEmpty()) return Pair(null, startIdx)
val n = numStr.toInt().coerceIn(0, 15)
return Pair(n, idx)
}
while (i < raw.length) {
when (raw[i]) {
BOLD -> {
flush(i); bold = !bold; i++; runStart = i
}
UNDERLINE -> {
flush(i); underline = !underline; i++; runStart = i
}
ITALIC -> {
flush(i); italic = !italic; i++; runStart = i
}
REVERSE -> {
flush(i); reverse = !reverse; i++; runStart = i
}
RESET -> {
flush(i)
bold = false; underline = false; italic = false; reverse = false; fg = null; bg = null
i++; runStart = i
}
COLOR -> {
flush(i)
i++
val (fgIdx, nextI) = readColorDigits(i)
i = nextI
if (fgIdx == null) {
fg = null; bg = null
} else {
fg = mircPalette[fgIdx]
if (i < raw.length && raw[i] == ',') {
val (bgIdx, nextI2) = readColorDigits(i + 1)
if (bgIdx != null) {
bg = mircPalette[bgIdx]
i = nextI2
}
}
}
runStart = i
}
else -> i++
}
}
flush(raw.length)
val parsed = builder.toAnnotatedString()
val matches = urlRegex.findAll(parsed.text).toList()
if (matches.isEmpty()) return parsed
// Layer link styling/annotations on top of the existing mIRC-colored string rather
// than replacing it, so control codes elsewhere in the line are unaffected.
val linked = AnnotatedString.Builder(parsed)
matches.forEach { match ->
val start = match.range.first
val end = match.range.last + 1
linked.addStyle(SpanStyle(color = urlColor, textDecoration = TextDecoration.Underline), start, end)
linked.addStringAnnotation(tag = "URL", annotation = match.value, start = start, end = end)
}
return linked.toAnnotatedString()
}