Files
wIRC-desktop/src/main/kotlin/irc/ConnectionManager.kt
T
weishauptandClaude Opus 5.5 3538274805 Make bare /quit disconnect only that server, keep windows open, bump to 0.1.9
/quit now disconnects the window's server without closing anything: every
buffer of that server and wIRC main get a "Disconnected" line, the server card
shows Disconnected with its Connect button, history is flushed to disk, and
there's no auto-reconnect. Connect rejoins the channels we were in (including
ones joined with /join, now tracked) into the still-open windows. Sending while
disconnected shows a "Not connected" line in that window. Exit wIRC still
disconnects everything and closes the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:46:59 -04:00

665 lines
34 KiB
Kotlin

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<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
// 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<Set<String>>(emptySet())
val connectedIds: StateFlow<Set<String>> = _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<String, MutableSet<String>>()
// A /whois we sent and haven't seen the end of yet. headerShown tracks whether its
// "WHOIS <nick>" 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<String, MutableMap<String, PendingWhois>>()
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<BufferKey>(extraBufferCapacity = 64)
val queryWindowRequests: SharedFlow<BufferKey> = _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<BufferKey> = 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 <nick>" 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<String, PendingWhois>? {
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<BufferKey, Job>()
// 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<ChannelUser>) -> List<ChannelUser>) {
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 <nick>" 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())
}
}
}
}
}