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
}
}
}
}
}
}