package irc import data.ServerConfig import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStreamWriter import java.io.PrintWriter import java.net.Socket import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory // TEMP DEBUG (see task: closed-channel-doesn't-receive-messages investigation) - remove // once the overnight rejoin issue is confirmed fixed. Prefixed so it's easy to grep out. private fun ircDebug(serverId: String, msg: String) { println("[irc-debug][$serverId] $msg") } // Extra buffer after end-of-MOTD (376/422) before actually sending on-connect commands - // see runPostRegistration(). Some networks' services genuinely aren't ready the same // instant MOTD ends; this is cheap insurance on top of that already-real trigger. private const val POST_MOTD_IDENTIFY_DELAY_MS = 1500L // on-connect commands are meant to be raw IRC protocol lines (e.g. // "PRIVMSG NickServ :IDENTIFY password"), but the app's own chat window teaches // "/msg NickServ IDENTIFY password" as the correct thing to type (it IS correct there - // ChannelWindow.kt's onSend translates that shorthand before sending) - an easy, natural // mistake to reuse that exact phrasing in the on-connect-commands field, which has no such // translation and just writes the string to the socket byte-for-byte. A real network // confirmed this: the raw line got rejected outright with "421 Unknown command" before // NickServ ever saw it, since IRC servers have no "/msg" command. This makes on-connect // commands tolerant of that shorthand too, so either form works. private fun translateOnConnectCommand(cmd: String): String { val trimmed = cmd.trim() if (!trimmed.startsWith("/msg ", ignoreCase = true)) return cmd val rest = trimmed.substring(5).trim() val spaceIdx = rest.indexOf(' ') if (spaceIdx <= 0) return cmd val target = rest.substring(0, spaceIdx) val message = rest.substring(spaceIdx + 1).trim() if (message.isEmpty()) return cmd return "PRIVMSG $target :$message" } /** * Owns one socket to one IRC server. Emits raw parsed lines via onLine. * Handles registration (NICK/USER), and basic auto-reconnect with backoff. */ class IrcConnection( val config: ServerConfig, private val scope: CoroutineScope, // Channels to rejoin on top of config.autoJoinChannels - the ones a previous connection // to this server was in (see ConnectionManager.connect). initialChannels: Collection = emptyList(), private val onLine: (ParsedLine) -> Unit, private val onStatus: (String) -> Unit ) { // @Volatile: read from the outbox-consumer coroutine, written from the read-loop // coroutine - without it there's no cross-thread visibility guarantee, so the outbox // consumer could keep observing a stale non-null `writer` from a socket that already // closed (or a stale null after a new one connected), silently mis-routing sends. @Volatile private var socket: Socket? = null @Volatile private var writer: PrintWriter? = null private var readJob: Job? = null // @Volatile: disconnect() clears it from the UI thread while the read loop checks it on // an IO thread - it's what stops the loop from auto-reconnecting after a /quit. @Volatile private var wantConnected = false private var backoffMillis = 2000L // Every channel we're currently in, whether from config.autoJoinChannels or joined // manually mid-session - so a reconnect rejoins everything, not just the original // auto-join list. private val joinedChannels = java.util.concurrent.CopyOnWriteArraySet(initialChannels) fun trackedChannels(): Set = joinedChannels.toSet() // Guards runPostRegistration() against running twice for the same connection cycle // (376 and 422 are mutually exclusive per RFC, but this is cheap insurance) - reset // to false at the top of every connect attempt so a reconnect re-arms it. @Volatile private var registeredHandled = false // Outgoing lines go through a single serial consumer instead of one // scope.launch(Dispatchers.IO) per send() call. That old approach had two problems: // (1) no ordering guarantee between independently-scheduled coroutines on a thread // pool dispatcher, and (2) if the socket dropped between a send() call being made and // its coroutine actually running, `writer` would already be null and the line was // silently discarded with no trace - which made a dropped JOIN indistinguishable from // one that was never requested. This queue preserves order and gives a single place // to log what actually made it onto the wire vs. what got dropped. private val outbox = Channel(Channel.UNLIMITED) init { scope.launch(Dispatchers.IO) { for (raw in outbox) { val w = writer if (w == null) { // Kept (unlike the old per-line CHECKPOINT4 write-confirmation logging, // removed - it fired on every single outbound line and was real // console-I/O overhead under busy-channel volume, per println being // synchronized/blocking) - this specific case should be rare and is // worth knowing about if it ever happens. ircDebug(config.id, "DROPPED (writer is null - not connected): \"$raw\"") } else { w.println(raw) } } } } fun connect() { wantConnected = true readJob?.cancel() readJob = scope.launch(Dispatchers.IO) { while (wantConnected && isActive) { var fallbackJob: Job? = null try { onStatus("Connecting to ${config.host}:${config.port}...") ircDebug(config.id, "connecting to ${config.host}:${config.port} (tracked channels: $joinedChannels)") val sock: Socket = if (config.useTls) { val sslSocket = SSLSocketFactory.getDefault().createSocket(config.host, config.port) as SSLSocket val sslParams = sslSocket.sslParameters sslParams.endpointIdentificationAlgorithm = "HTTPS" sslSocket.sslParameters = sslParams sslSocket.startHandshake() sslSocket } else { Socket(config.host, config.port) } socket = sock // disconnect() can't interrupt a socket that's still being opened, so // check here before registering on a connection nobody wants anymore. ensureActive() val out = PrintWriter(OutputStreamWriter(sock.getOutputStream(), Charsets.UTF_8), true) writer = out // Used to send "CAP REQ :sasl" here whenever config.saslUser was set, but // there was never any AUTHENTICATE/CAP END follow-through - SASL isn't // actually implemented (there's no UI to even set saslUser/saslPass). Per // the IRCv3 CAP spec, a compliant server MUST hold off sending 001 until // it sees CAP END, so starting a negotiation the client can never finish // risked silently stalling registration forever on strict servers - // meaning 001/376/422 (and therefore the on-connect identify command) // would never fire at all. Removed until SASL is actually implemented // end-to-end. out.println("NICK ${config.nick}") out.println("USER ${config.nick} 0 * :${config.nick}") backoffMillis = 2000L onStatus("Connected, registering...") registeredHandled = false // On-connect commands (identify, etc.) used to fire straight off 001 - // technically "registered" but too early on plenty of real networks: // NickServ/services in general aren't guaranteed ready for PRIVMSGs the // instant 001 lands, which is exactly why real clients (mIRC, HexChat, // irssi) fire "perform"/on-connect scripts off end-of-MOTD (376/422) // instead - see runPostRegistration(). This matches that convention, with // an 8s fallback in case a server never sends either (rare, but some // minimal/bouncer-style endpoints omit MOTD numerics entirely). fallbackJob = scope.launch { delay(8000) if (!registeredHandled) { ircDebug(config.id, "no 376/422 within 8s of connecting - running perform+rejoin anyway (fallback)") runPostRegistration() } } val reader = BufferedReader(InputStreamReader(sock.getInputStream(), Charsets.UTF_8)) while (isActive) { val line = reader.readLine() ?: break handleRawLine(line) } } catch (e: Exception) { // After disconnect() the read fails because we closed the socket // ourselves - that's not an error worth reporting. if (!wantConnected) break onStatus("Connection error: ${e.message}") ircDebug(config.id, "connection dropped: ${e.message} - tracked channels at drop time: $joinedChannels") } finally { fallbackJob?.cancel() closeSocketQuietly() } if (wantConnected) { onStatus("Reconnecting in ${backoffMillis / 1000}s...") ircDebug(config.id, "reconnecting in ${backoffMillis}ms") delay(backoffMillis) backoffMillis = (backoffMillis * 2).coerceAtMost(60_000L) } } } } private fun handleRawLine(line: String) { // Used to log every single raw line here (RECV: "...") - removed. It was the // single highest-volume debug line in the app: one per incoming line, every line, // forever, and println is synchronized/blocking - a real, measurable cost in a // busy channel, not just noise. The specific 001/376/422 confirmations below are // once-per-connect and stay. if (line.startsWith("PING")) { val token = line.substringAfter("PING ") send("PONG $token") return } val parsed = parseIrcLine(line) ?: return // CTCP requests arrive as a PRIVMSG wrapped in \x01...\x01. VERSION gets // handled right here so it never shows up in a channel/PM window - real // clients answer these silently too. if (parsed.command == "PRIVMSG") { val text = parsed.params.getOrNull(1) if (text != null && text.startsWith("") && text.endsWith("") && text.length > 1) { val ctcpBody = text.removePrefix("").removeSuffix("") val ctcpCommand = ctcpBody.substringBefore(' ').uppercase() if (ctcpCommand == "VERSION") { val replyTo = nickFromPrefix(parsed.prefix) if (replyTo != null) { send("NOTICE $replyTo :VERSION It's an irc client, fucko!") } return } // Other CTCP types (ACTION, PING, etc.) fall through to onLine as normal - // ACTION in particular still needs to reach ConnectionManager to render as /me. } } if (parsed.command == "001") { ircDebug(config.id, "registered (001) - awaiting end-of-MOTD (376/422) before perform+rejoin") } if (parsed.command == "376" || parsed.command == "422") { runPostRegistration() } onLine(parsed) } // Runs the on-connect ("perform") commands - NickServ/ChanServ identify, etc. - then // rejoins every channel we were actually in (the configured auto-join list plus // anything joined manually mid-session, so a reconnect doesn't forget those). Fired // off end-of-MOTD rather than bare 001 - see the comment in connect() for why - so // identify commands land once the session is actually settled enough for services to // reliably act on them. private fun runPostRegistration() { if (registeredHandled) return registeredHandled = true // The whole sequence (identify, then rejoin) runs inside this delayed coroutine // rather than just delaying the identify send - keeping both here preserves "run // identify before joining anything that might require it" (rejoin used to run // synchronously right after, which would otherwise now race ahead of a delayed // identify send and reach the wire first). The delay itself is a few extra hundred // ms of insurance on top of an already-real event (376/422) rather than a fixed // guess from connection time - some networks' NickServ genuinely isn't ready to // process a command in the same instant MOTD ends. scope.launch(Dispatchers.IO) { delay(POST_MOTD_IDENTIFY_DELAY_MS) config.onConnectCommands.forEach { cmd -> if (cmd.isNotBlank()) send(translateOnConnectCommand(cmd)) } val rejoinList = (config.autoJoinChannels + joinedChannels).distinct() ircDebug(config.id, "post-registration - rejoining: $rejoinList") rejoinList.forEach { ch -> joinChannel(ch) } } } fun send(raw: String) { outbox.trySend(raw) } fun sendMessage(target: String, text: String) { send("PRIVMSG $target :$text") } fun joinChannel(channel: String) { joinedChannels.add(channel) ircDebug(config.id, "joinChannel($channel) - tracked set now: $joinedChannels") send("JOIN $channel") } fun partChannel(channel: String, reason: String = "") { joinedChannels.remove(channel) ircDebug(config.id, "partChannel($channel) - tracked set now: $joinedChannels") send("PART $channel :$reason") } fun kick(channel: String, nick: String, reason: String = "") = send("KICK $channel $nick :$reason") fun setMode(channel: String, modeChange: String, nick: String) = send("MODE $channel $modeChange $nick") fun ban(channel: String, mask: String) = send("MODE $channel +b $mask") fun disconnect() { wantConnected = false scope.launch(Dispatchers.IO) { try { writer?.println("QUIT :leaving") } catch (_: Exception) {} readJob?.cancel() closeSocketQuietly() // Ends the outbox consumer - this connection is never reused (a reconnect // makes a new IrcConnection), so it would otherwise idle forever. outbox.close() } } private fun closeSocketQuietly() { try { socket?.close() } catch (_: Exception) {} socket = null writer = null } }