package irc import data.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel 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 java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale 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 // WHOIS reply numerics. Each one's params are [ourNick, targetNick, ...], so the target // nick (params[1]) is what ties a reply line back to the /whois that asked for it. // 318 is RPL_ENDOFWHOIS. These only ever come from a WHOIS, so they're shown as whois // output even when no /whois of ours is pending (e.g. one sent through an alias). private val WHOIS_NUMERICS = setOf( "275", "276", "301", "307", "310", "311", "312", "313", "317", "318", "319", "320", "330", "335", "338", "378", "379", "671" ) // How long after a /whois is sent that a NOTICE from the server or services still counts // as a response to it (e.g. a server-side ignore / caller-id notice), and so is labelled // and grouped under that whois in wIRC main. private const val WHOIS_NOTICE_WINDOW_MS = 10_000L // A /whois that never got its 318 (lost reply, server that doesn't send one) stops being // pending after this long, so stray numerics much later aren't attributed to it. private const val WHOIS_PENDING_TIMEOUT_MS = 60_000L // Numerics that only make sense inside a channel window and are already reflected there // (353 fills the user list, see handleLine) or carry nothing worth showing on their own - // kept out of wIRC main so joining a channel doesn't flood it. private val CHANNEL_ONLY_NUMERICS = setOf("332", "333", "353", "366") /** * 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() private val ownNicks = ConcurrentHashMap() // serverId -> our current nick private var historyRepo: MessageHistoryRepository? = null private val _buffers = MutableStateFlow>(emptyMap()) val buffers: StateFlow> = _buffers private val _statuses = MutableStateFlow>(emptyMap()) val statuses: StateFlow> = _statuses private val _ownNicks = MutableStateFlow>(emptyMap()) val ownNicksFlow: StateFlow> = _ownNicks // Servers that have a live connection (or one that's connecting/auto-reconnecting after // a network drop). A server leaves this set only through disconnect()/disconnectAll(), // and it's what wIRC main's server cards show as Connected / Disconnected. private val _connectedIds = MutableStateFlow>(emptySet()) val connectedIds: StateFlow> = _connectedIds // serverId -> channels we were in when /quit disconnected that server, so the next // connect() rejoins them (and the windows still open for them pick right back up) // instead of only the configured auto-join list. private val channelsToRejoin = ConcurrentHashMap>() // A /whois we sent and haven't seen the end of yet. headerShown tracks whether its // "WHOIS " header line is already in wIRC main. private class PendingWhois(val nick: String, val sentAtMillis: Long, var headerShown: Boolean) // serverId -> (lowercased target nick -> pending whois). Only ever touched from the // bufferOps consumer coroutine (see enqueue), so a plain map is enough. private val pendingWhois = HashMap>() fun init(rootDir: File) { historyRepo = MessageHistoryRepository(rootDir) // Seeds wIRC main from its saved history right away, so its scrollback is there at // startup instead of appearing only once the first server line arrives. enqueue { ensureBuffer(MAIN_BUFFER_KEY) } } private fun setOwnNick(serverId: String, nick: String) { ownNicks[serverId] = nick _ownNicks.update { it + (serverId to nick) } } // Fires for every private message to or from a real user (services and the server go // to wIRC main instead). Main.kt adds the key to its set of open windows, which opens // the query window if it isn't open - including reopening one the user closed - and // is a no-op if it already is. private val _queryWindowRequests = MutableSharedFlow(extraBufferCapacity = 64) val queryWindowRequests: SharedFlow = _queryWindowRequests // Every buffer state change - incoming lines, local echoes of our own sends - goes // through this single FIFO queue, processed by one consumer coroutine. Previously each // addMessage ran as its own independently-launched coroutine on Dispatchers.IO, guarded // only by a per-key Mutex; a Mutex makes each key's critical section exclusive but gives // no ordering guarantee between separate launches racing to acquire it, so lines in a // busy channel could commit to the buffer out of order. Routing everything through one // queue with one consumer makes "posted first, applied first" an actual guarantee instead // of a usual case, and lets addMessage/updateUsers/the TOPIC handler go back to being // plain synchronous code - they're never called from anywhere except this one coroutine. private val bufferOps = Channel<() -> Unit>(Channel.UNLIMITED) init { scope.launch(Dispatchers.IO) { for (op in bufferOps) { try { op() } catch (e: Exception) { // One bad op must not take down the consumer loop - that would silently // stop ALL future buffer updates for the rest of the process. e.printStackTrace() } } } } private fun enqueue(op: () -> Unit) { bufferOps.trySend(op) } fun connect(config: ServerConfig) { 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. existing.disconnect() } setOwnNick(config.id, config.nick) // Channels from the connection a /quit ended, or from the one being replaced, so // they're rejoined along with the auto-join list. val rejoin = channelsToRejoin.remove(config.id).orEmpty() + existing?.trackedChannels().orEmpty() lateinit var conn: IrcConnection conn = IrcConnection( config = config, scope = scope, initialChannels = rejoin, // Lines and status updates from a connection that's already been disconnected // or replaced (its socket closing, a last line in flight) are dropped, so // nothing shows up after the "Disconnected" line or overwrites that status. onLine = { line -> if (connections[config.id] === conn) enqueue { handleLine(config.id, line) } }, onStatus = { msg -> if (connections[config.id] === conn) { _statuses.update { it + (config.id to msg) } // Connection progress and errors aren't tied to any channel - wIRC main. enqueue { addMainLine(MessageKind.SYSTEM, null, "[${config.label}] $msg") } } } ) connections[config.id] = conn _connectedIds.update { it + config.id } conn.connect() } /** * Bare /quit: disconnects this one server and nothing else. Windows stay open and keep * their scrollback; every buffer of this server (plus [openKeys], the open windows, in * case one hasn't had a line yet) gets a "Disconnected" line, and so does wIRC main. * There's no auto-reconnect afterward - the next connect() (the server card's Connect * button) rejoins the channels we were in. History is written to disk right away, the * same as disconnectAll() does on exit. */ fun disconnect(serverId: String, openKeys: Collection = emptyList()) { val conn = connections.remove(serverId) ?: return conn.disconnect() channelsToRejoin[serverId] = conn.trackedChannels().toMutableSet() _connectedIds.update { it - serverId } _statuses.update { it + (serverId to "Disconnected") } val label = conn.config.label enqueue { pendingWhois.remove(serverId) val now = System.currentTimeMillis() val keys = _buffers.value.keys.filter { it.serverId == serverId } + openKeys.filter { it.serverId == serverId } keys.distinct().forEach { key -> addMessage(key, IrcMessage(now, MessageKind.SYSTEM, null, "Disconnected from $label")) } // We're no longer in any channel, so the user lists are stale - the NAMES reply // on rejoin fills them again. _buffers.update { map -> map.mapValues { (key, buf) -> if (key.serverId == serverId) buf.copy(users = emptyList()) else buf } } addMainLine(MessageKind.SYSTEM, null, "[$label] Disconnected") // Runs on the bufferOps queue after the lines above, so they're in the save. flushAllSaves() } } fun disconnectAll() { connections.values.forEach { it.disconnect() } connections.clear() _connectedIds.value = emptySet() flushAllSaves() } fun isConnected(serverId: String): Boolean = connections.containsKey(serverId) // /part from a channel window. While disconnected there's no PART to send, but the // channel is dropped from the rejoin list so reconnecting doesn't rejoin it. fun partChannel(serverId: String, channel: String, reason: String) { val conn = connectionFor(serverId) if (conn != null) conn.partChannel(channel, reason) else channelsToRejoin[serverId]?.remove(channel) } // /join from a channel window. A plain channel list is tracked (see // IrcConnection.joinChannel) so a reconnect rejoins it; one with a key is sent as-is, // since rejoining it later without the key wouldn't work anyway. fun joinChannels(serverId: String, args: String) { val conn = connectionFor(serverId) ?: return if (args.contains(' ')) conn.send("JOIN $args") else args.split(',').filter { it.isNotBlank() }.forEach { conn.joinChannel(it) } } 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) { // No local echo while disconnected - the line was never sent. val conn = connectionFor(serverId) ?: return conn.sendMessage(target, text) val myNick = ownNicks[serverId] ?: return val msg = IrcMessage(System.currentTimeMillis(), MessageKind.CHAT, myNick, text) enqueue { addOutgoing(serverId, target, msg, "-> *$target* $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) { val conn = connectionFor(serverId) ?: return conn.sendMessage(target, "\u0001ACTION $action\u0001") val myNick = ownNicks[serverId] ?: return val msg = IrcMessage(System.currentTimeMillis(), MessageKind.ACTION, myNick, action) enqueue { addOutgoing(serverId, target, msg, "-> *$target* * $myNick $action") } } // Where a local echo goes: a channel's own buffer, wIRC main for a service (e.g. // "/msg NickServ ..."), or a query buffer - opening its window - for a real user. private fun addOutgoing(serverId: String, target: String, msg: IrcMessage, mainText: String) { when { isChannelName(target) -> addMessage(BufferKey(serverId, target), msg) isServiceNick(target) -> addMainLine(MessageKind.SYSTEM, null, mainText) else -> { val key = BufferKey(serverId, target) addMessage(key, msg) _queryWindowRequests.tryEmit(key) } } } // Raw passthrough for slash commands that don't have dedicated handling. fun sendRaw(serverId: String, raw: String) { connectionFor(serverId)?.send(raw) } /** * Sends WHOIS. All of its output - the header, every reply line, and any server or * services notice that comes back in response - goes to wIRC main, whichever window the * command was typed in. [args] is whatever followed "/whois"; its last word is the nick * being looked up (the "/whois server nick" form puts the nick last too), and may be a * comma-separated list. */ fun whois(serverId: String, args: String) { val label = connectionFor(serverId)?.config?.label val nicks = args.trim().split(Regex("\\s+")).lastOrNull() ?.split(',')?.filter { it.isNotEmpty() }.orEmpty() if (nicks.isEmpty() || label == null) { val text = if (nicks.isEmpty()) "[whois] Usage: /whois " else "[whois] Not connected to this server" enqueue { addMainLine(MessageKind.SYSTEM, null, text) } return } // Enqueued before the WHOIS goes out, so the header is always in the buffer ahead // of any reply (replies come in through the same queue, and can't arrive before // the command is sent). Each further nick of a comma list gets its header when its // first reply line arrives, so the lines end up grouped under the right one. enqueue { val now = System.currentTimeMillis() val pending = pendingWhois.getOrPut(serverId) { HashMap() } nicks.forEach { pending[it.lowercase()] = PendingWhois(it, now, headerShown = false) } showWhoisHeader(serverId, pending.getValue(nicks.first().lowercase())) } sendRaw(serverId, "WHOIS ${args.trim()}") } private fun showWhoisHeader(serverId: String, pending: PendingWhois) { if (pending.headerShown) return pending.headerShown = true val label = connectionFor(serverId)?.config?.label ?: serverId // \u0002 is the mIRC bold toggle, so the header stands out from the reply lines. addMainLine(MessageKind.SYSTEM, null, "\u0002===== WHOIS ${pending.nick} ($label) =====\u0002") } // Pending whois for this server, with any that timed out dropped first. private fun livePendingWhois(serverId: String): MutableMap? { val pending = pendingWhois[serverId] ?: return null val cutoff = System.currentTimeMillis() - WHOIS_PENDING_TIMEOUT_MS pending.values.removeAll { it.sentAtMillis < cutoff } return pending } // A numeric is whois output if it's one of WHOIS_NUMERICS, or if it names a nick we // have a /whois pending for (401/402 "no such nick/server", 716 "is in +g mode // (server-side ignore)", ...), or if it's about the WHOIS command itself (263 // "try again", 481, ...) while one is pending. private fun isWhoisNumeric(serverId: String, line: ParsedLine): Boolean { if (line.command in WHOIS_NUMERICS) return true if (!line.command.all { it.isDigit() }) return false val pending = livePendingWhois(serverId) if (pending.isNullOrEmpty()) return false val subject = line.params.getOrNull(1) ?: return false return subject.lowercase() in pending || subject.equals("WHOIS", ignoreCase = true) } // The most recently sent /whois on this server, if it went out recently enough that a // server or services NOTICE arriving now is plausibly a response to it. private fun whoisAwaitingNotice(serverId: String): PendingWhois? { val recent = livePendingWhois(serverId)?.values?.maxByOrNull { it.sentAtMillis } ?: return null return recent.takeIf { System.currentTimeMillis() - it.sentAtMillis <= WHOIS_NOTICE_WINDOW_MS } } // 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) { val key = BufferKey(serverId, target) val msg = IrcMessage(System.currentTimeMillis(), MessageKind.SYSTEM, null, text) enqueue { addMessage(key, msg) } } // Pending debounced saves, keyed by buffer - see scheduleSave(). private val pendingSaveJobs = ConcurrentHashMap() // If this key's buffer doesn't exist yet, seeds it from disk history before anything // else touches it. Every code path that can create a buffer (addMessage, updateUsers, // the TOPIC handler) goes through this first, so a buffer's first appearance in // _buffers always carries its persisted history - never an empty one that a later // debounced save would then overwrite the real history file with. This only ever runs // on the single bufferOps consumer coroutine, so the "does it exist" check and the // insert-with-seeded-history that follows can't race with another caller doing the // same thing for the same key. private fun ensureBuffer(key: BufferKey) { if (_buffers.value.containsKey(key)) return val seeded = historyRepo?.loadMessages(key) ?: emptyList() _buffers.update { map -> map + (key to ChannelBuffer(key, messages = seeded)) } } private fun addMessage(key: BufferKey, msg: IrcMessage) { ensureBuffer(key) _buffers.update { map -> val existing = map[key] ?: ChannelBuffer(key) val trimmed = (existing.messages + msg).takeLast(MESSAGE_HISTORY_CAP) map + (key to existing.copy(messages = trimmed)) } scheduleSave(key) } private fun addMainLine(kind: MessageKind, sender: String?, text: String) { addMessage(MAIN_BUFFER_KEY, IrcMessage(System.currentTimeMillis(), kind, sender, text)) } // 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) -> List) { ensureBuffer(key) _buffers.update { map -> val existing = map[key] ?: ChannelBuffer(key) map + (key to existing.copy(users = transform(existing.users))) } } // Every WHOIS reply line goes to wIRC main, under its "WHOIS " header. A reply // with no pending /whois (one sent through an alias or a raw command) gets a header // of its own the same way, so every whois result in wIRC main starts with one. private fun handleWhoisLine(serverId: String, line: ParsedLine) { val target = line.params.getOrNull(1) ?: return val pending = livePendingWhois(serverId) val entry = pending?.get(target.lowercase()) ?: if (target.equals("WHOIS", ignoreCase = true)) pending?.values?.maxByOrNull { it.sentAtMillis } else null when { entry != null -> showWhoisHeader(serverId, entry) line.command in WHOIS_NUMERICS && line.command != "318" -> { val unsolicited = PendingWhois(target, System.currentTimeMillis(), headerShown = false) pendingWhois.getOrPut(serverId) { HashMap() }[target.lowercase()] = unsolicited showWhoisHeader(serverId, unsolicited) } } if (line.command == "318") { // A comma-list whois ends with a single 318 naming the whole list. target.split(',').forEach { pending?.remove(it.lowercase()) } } addMainLine(MessageKind.SYSTEM, null, "[whois] " + formatWhoisLine(line)) } private fun formatWhoisLine(line: ParsedLine): String { val p = line.params val nick = p.getOrNull(1) ?: "" return when (line.command) { "311" -> "$nick is ${p.getOrNull(2)}@${p.getOrNull(3)} (${p.lastOrNull() ?: ""})" "312" -> "$nick is on ${p.getOrNull(2)} (${p.lastOrNull() ?: ""})" "317" -> { val idleSecs = p.getOrNull(2)?.toLongOrNull() val signon = p.getOrNull(3)?.toLongOrNull() buildString { append("$nick has been idle ${idleSecs?.let { formatDuration(it) } ?: "?"}") if (signon != null) { val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) append(", signed on ${fmt.format(Date(signon * 1000))}") } } } "319" -> "$nick is on channels: ${p.lastOrNull() ?: ""}" "330" -> "$nick ${p.lastOrNull() ?: "is logged in as"} ${p.getOrNull(2) ?: ""}".trim() "301" -> "$nick is away: ${p.lastOrNull() ?: ""}" "318" -> "End of WHOIS for $nick" else -> p.drop(1).joinToString(" ") } } private fun formatDuration(totalSecs: Long): String { val d = totalSecs / 86400 val h = (totalSecs % 86400) / 3600 val m = (totalSecs % 3600) / 60 val s = totalSecs % 60 return buildString { if (d > 0) append("${d}d ") if (d > 0 || h > 0) append("${h}h ") if (d > 0 || h > 0 || m > 0) append("${m}m ") append("${s}s") } } 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 isCtcp = text.length > 1 && text.startsWith("\u0001") && text.endsWith("\u0001") val isAction = isCtcp && text.startsWith("\u0001ACTION") val body = if (isAction) text.removePrefix("\u0001ACTION ").removeSuffix("\u0001") else text if (isChannelName(target)) { addMessage( BufferKey(serverId, target), IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body) ) } else if (isServiceOrServerPrefix(line.prefix) || (isCtcp && !isAction)) { // Services, the server itself, and CTCP requests other than ACTION // (PING, TIME, ...) go to wIRC main instead of opening a query window. // (VERSION is already answered and swallowed in IrcConnection.) val shown = if (isCtcp && !isAction) "[CTCP ${text.trim('\u0001')} from $nick]" else body addMainLine(MessageKind.CHAT, nick, shown) } else { // A private message from a real user arrives with target == our own // nick, so key it by the *sender* - that's the conversation identity a // query window should use - and (re)open that window. val key = BufferKey(serverId, nick ?: target) addMessage(key, IrcMessage(now, if (isAction) MessageKind.ACTION else MessageKind.CHAT, nick, body)) _queryWindowRequests.tryEmit(key) } } "NOTICE" -> { val target = line.params.getOrNull(0) ?: return val text = line.params.getOrNull(1) ?: return if (isChannelName(target)) { addMessage(BufferKey(serverId, target), IrcMessage(now, MessageKind.NOTICE, nick, text)) } else if (isServiceOrServerPrefix(line.prefix)) { // NickServ/ChanServ replies (identify success/failure etc.) and server // notices - wIRC main. The sender goes into the text because the // NOTICE row layout doesn't show a nick column. One arriving right // after a /whois (e.g. a server-side ignore notice) is labelled as part // of that whois and goes under its header. val whois = whoisAwaitingNotice(serverId) if (whois != null) showWhoisHeader(serverId, whois) val label = if (whois != null) "[whois] " else "" addMainLine(MessageKind.NOTICE, nick, "$label-${nick ?: "server"}- $text") } else { // A notice directed AT us arrives with target == our own nick, so key // it by the sender, same as a private message. val key = BufferKey(serverId, nick ?: target) addMessage(key, IrcMessage(now, MessageKind.NOTICE, nick, text)) _queryWindowRequests.tryEmit(key) } } "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 val modeStr = line.params.getOrNull(1) ?: "" val modeTarget = line.params.getOrNull(2) if (!isChannelName(channel)) { // User mode on our own nick (e.g. +i, +r after identifying) - wIRC main. addMainLine(MessageKind.MODE, nick, "$nick sets mode $modeStr on $channel") return } 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) ?: "" val key = BufferKey(serverId, channel) ensureBuffer(key) _buffers.update { map -> val existing = map[key] ?: ChannelBuffer(key) map + (key to existing.copy(topic = topic)) } addMessage(key, 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 } } } "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) addMainLine(MessageKind.NICK, null, "You are now known as $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 } } } "INVITE" -> { val channel = line.params.getOrNull(1) ?: return addMainLine(MessageKind.SYSTEM, nick, "$nick invited you to $channel") } "ERROR" -> { addMainLine(MessageKind.SYSTEM, null, "ERROR: ${line.params.joinToString(" ")}") } "PONG", "CAP" -> Unit else -> { when { isWhoisNumeric(serverId, line) -> handleWhoisLine(serverId, line) line.command in CHANNEL_ONLY_NUMERICS -> Unit // Every other numeric (welcome, ISUPPORT, LUSERS, MOTD, errors, ...) is a // server message; params[0] is just our own nick, so it's dropped. line.command.all { it.isDigit() } -> addMainLine(MessageKind.SYSTEM, line.prefix, line.params.drop(1).joinToString(" ")) // Anything else not tied to a channel (WALLOPS, KILL, ...) - wIRC main as-is. else -> addMainLine(MessageKind.SYSTEM, nick, "${line.command} ${line.params.joinToString(" ")}".trim()) } } } } }