/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>
665 lines
34 KiB
Kotlin
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())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|