/** * WebIRC Chat - IRC Message Handler * * @package WebIRC_Chat * @license GPL-2.0-or-later * @since 0.1.0 */ import { parseIrc } from './irc'; import { logLine } from './ui'; import { maskSensitive } from './util'; import type { UiElements, Config, IrcMessage } from './types'; /** * Message handler context interface. * * @since 0.1.0 */ export interface MessageContext { ui: UiElements; cfg: Config; send: (line: string) => void; nick: string; currentChannel: string; updateNickDisplay: (newNick: string) => void; updateChannelDisplay: (channel: string) => void; setNick: (newNick: string) => void; setLastAttemptedNick: (nick: string) => void; setUsers?: ( users: Array<{ nick: string; status?: 'online' | 'away' | 'offline' }> ) => void; debugLog: (message: string) => void; } /** * Process incoming IRC data and handle messages. * * @since 0.1.0 */ export function processIrcData(data: string, context: MessageContext): void { const lines = String(data).split(/\r\n/); for (const line of lines) { if (!line) continue; // Only show raw IRC protocol messages in debug mode and mask sensitive data if (context.cfg.debugLogs) { logLine(context.ui.log, `[IRC] ${maskSensitive(line)}`, 'notice'); } const message = parseIrc(line); if (!message.cmd) continue; handleIrcMessage(message, context); } } /** * Handle individual IRC message based on command. * * @since 0.1.0 */ function handleIrcMessage(message: IrcMessage, context: MessageContext): void { const { cmd } = message; // Handle PING immediately if (cmd === 'PING') { handlePing(message, context); return; } // Connection and registration messages if (cmd === '001') handleWelcome(message, context); if (cmd === '376' || cmd === '422') handleMotdEnd(message, context); if (cmd === '433') handleNickInUse(message, context); // Channel events if (cmd === 'JOIN') handleJoin(message, context); if (cmd === 'PART') handlePart(message, context); if (cmd === 'QUIT') handleQuit(message, context); if (cmd === 'NICK') handleNickChange(message, context); if (cmd === 'TOPIC') handleTopicChange(message, context); // Messages if (cmd === 'PRIVMSG') handlePrivmsg(message, context); if (cmd === 'NOTICE') handleNotice(message, context); // Information responses if (cmd === '311') handleWhoisUser(message, context); if (cmd === '312') handleWhoisServer(message, context); if (cmd === '317') handleWhoisIdle(message, context); if (cmd === '318') handleWhoisEnd(message, context); if (cmd === '352') handleWhoReply(message, context); if (cmd === '315') handleWhoEnd(message, context); if (cmd === '322') handleListReply(message, context); if (cmd === '323') handleListEnd(message, context); if (cmd === '332') handleTopicReply(message, context); if (cmd === '331') handleNoTopic(message, context); if (cmd === '353') handleNamesReply(message, context); if (cmd === '366') handleNamesEnd(message, context); // Status responses if (cmd === '306') handleNowAway(message, context); if (cmd === '305') handleUnaway(message, context); if (cmd === '301') handleAwayReply(message, context); if (cmd === '391') handleTimeReply(message, context); if (cmd === '351') handleVersionReply(message, context); // Server information responses if (cmd === '372') handleMotdReply(message, context); if (cmd === '375') handleMotdStart(message, context); if (cmd === '376') handleMotdEnd(message, context); if (cmd === '422') handleNoMotd(message, context); if (cmd === '256') handleAdminMe(message, context); if (cmd === '257') handleAdminLoc1(message, context); if (cmd === '258') handleAdminLoc2(message, context); if (cmd === '259') handleAdminEmail(message, context); if (cmd === '371') handleInfoReply(message, context); if (cmd === '374') handleEndOfInfo(message, context); if (cmd === '211') handleStatsLinkInfo(message, context); if (cmd === '212') handleStatsCommands(message, context); if (cmd === '213') handleStatsCLine(message, context); if (cmd === '214') handleStatsNLine(message, context); if (cmd === '215') handleStatsILine(message, context); if (cmd === '216') handleStatsKLine(message, context); if (cmd === '218') handleStatsYLine(message, context); if (cmd === '219') handleEndOfStats(message, context); if (cmd === '241') handleStatsLLine(message, context); if (cmd === '242') handleStatsUptime(message, context); if (cmd === '243') handleStatsOLine(message, context); if (cmd === '244') handleStatsHLine(message, context); if (cmd === '364') handleLinksReply(message, context); if (cmd === '365') handleEndOfLinks(message, context); if (cmd === '251') handleLusersClient(message, context); if (cmd === '252') handleLusersOp(message, context); if (cmd === '253') handleLusersUnknown(message, context); if (cmd === '254') handleLusersChannels(message, context); if (cmd === '255') handleLusersMe(message, context); if (cmd === '302') handleUserhostReply(message, context); if (cmd === '303') handleIsonReply(message, context); } /** * Handle PING messages. * * @since 0.1.0 */ function handlePing(message: IrcMessage, context: MessageContext): void { const cookie = message.args[message.args.length - 1] || ''; const pongMsg = cookie.startsWith(':') ? `PONG ${cookie}` : `PONG :${cookie}`; context.send(pongMsg); context.debugLog(`Received PING: ${message.raw}`); context.debugLog(`Sending: ${pongMsg}`); } /** * Handle welcome message (001). * * @since 0.1.0 */ function handleWelcome(message: IrcMessage, context: MessageContext): void { context.debugLog('Registration successful! Welcome message received'); context.send(`JOIN ${context.cfg.channel}`); } /** * Handle end of MOTD. * * @since 0.1.0 */ function handleMotdEnd(message: IrcMessage, context: MessageContext): void { context.debugLog('MOTD complete, ready for chat'); } /** * Handle nickname in use error (433). * * @since 0.1.0 */ function handleNickInUse(message: IrcMessage, context: MessageContext): void { // Get the attempted nickname from the IRC message (second argument) const attemptedNick = message.args[1] || context.nick; const randomSuffix = Math.floor(Math.random() * 1000); const baseNick = attemptedNick.replace(/\d+$/, ''); // Remove any trailing numbers const newNick = baseNick + randomSuffix; context.setNick(newNick); context.setLastAttemptedNick(newNick); context.updateNickDisplay(newNick); context.send(`NICK ${newNick}`); logLine( context.ui.log, `• Nickname ${attemptedNick} in use, trying ${newNick}`, 'notice' ); } /** * Handle JOIN messages. * * @since 0.1.0 */ function handleJoin(message: IrcMessage, context: MessageContext): void { const who = (message.prefix || '').split('!')[0] || '???'; let channel = message.args[0] || ''; // JOIN channel is often the last parameter, remove leading colon if present if (channel.startsWith(':')) { channel = channel.slice(1); } context.debugLog( `JOIN - who: "${who}", nick: "${context.nick}", raw channel: "${message.args[0]}", cleaned: "${channel}"` ); if (who === context.nick) { logLine(context.ui.log, `• Joined ${channel}`, 'sys'); context.updateChannelDisplay(channel); context.debugLog(`Updated currentChannel to: "${channel}"`); } else { logLine(context.ui.log, `• ${who} joined`, 'sys'); } } /** * Handle PART messages. * * @since 0.1.0 */ function handlePart(message: IrcMessage, context: MessageContext): void { const who = (message.prefix || '').split('!')[0] || '???'; const partedChannel = (message.args[0] || '').replace(/^:/, ''); if (who === context.nick) { logLine( context.ui.log, `• You left ${partedChannel || context.currentChannel}`, 'sys' ); // Clear current channel if we left it if ( !partedChannel || partedChannel.toLowerCase() === context.currentChannel.toLowerCase() ) { context.updateChannelDisplay(''); } } else { logLine(context.ui.log, `• ${who} left`, 'sys'); } } /** * Handle QUIT messages. * * @since 0.1.0 */ function handleQuit(message: IrcMessage, context: MessageContext): void { const who = (message.prefix || '').split('!')[0] || '???'; const reason = message.args[message.args.length - 1] || ''; logLine( context.ui.log, `• ${who} quit${reason ? ' (' + reason + ')' : ''}`, 'sys' ); } /** * Handle NICK change messages. * * @since 0.1.0 */ function handleNickChange(message: IrcMessage, context: MessageContext): void { const who = (message.prefix || '').split('!')[0] || '???'; const rawNewNick = message.args[0] || ''; const newNick = rawNewNick.startsWith(':') ? rawNewNick.slice(1) : rawNewNick; if (who === context.nick) { context.setNick(newNick); context.setLastAttemptedNick(newNick); logLine(context.ui.log, `• You are now known as ${newNick}`, 'sys'); context.updateNickDisplay(newNick); } else { logLine(context.ui.log, `• ${who} is now known as ${newNick}`, 'sys'); } } /** * Handle PRIVMSG messages. * * @since 0.1.0 */ function handlePrivmsg(message: IrcMessage, context: MessageContext): void { const from = (message.prefix || '').split('!')[0] || '???'; let target = message.args[0] || ''; const text = message.args[message.args.length - 1] || ''; // Clean target (remove colon if present) if (target.startsWith(':')) { target = target.slice(1); } context.debugLog( `PRIVMSG - raw target: "${message.args[0]}", cleaned target: "${target}", currentChannel: "${context.currentChannel}"` ); // Handle CTCP messages if (text.startsWith('\u0001') && text.endsWith('\u0001')) { handleCtcpMessage(message, context, from, text); return; } // Case-insensitive channel comparison (IRC channels are case-insensitive) if (target.toLowerCase() === context.currentChannel.toLowerCase()) { logLine(context.ui.log, `${from} ▸ ${text}`); } else { logLine(context.ui.log, `[PM] ${from} ▸ ${text}`, 'notice'); } } /** * Handle CTCP messages within PRIVMSG. * * @since 0.1.0 */ function handleCtcpMessage( message: IrcMessage, context: MessageContext, from: string, text: string ): void { const ctcpMsg = text.slice(1, -1); if (ctcpMsg.startsWith('ACTION ')) { // Handle /me actions const action = ctcpMsg.slice(7); logLine(context.ui.log, `* ${from} ${action}`, 'action'); } else if (ctcpMsg.startsWith('PING ')) { // Respond to PING const pingData = ctcpMsg.slice(5); context.send(`NOTICE ${from} :\u0001PING ${pingData}\u0001`); logLine(context.ui.log, `• PING request from ${from}`, 'notice'); } else if (ctcpMsg === 'VERSION') { // Respond to VERSION context.send(`NOTICE ${from} :\u0001VERSION WebIRC Chat 0.1.0\u0001`); logLine(context.ui.log, `• VERSION request from ${from}`, 'notice'); } else { logLine(context.ui.log, `• CTCP ${ctcpMsg} from ${from}`, 'notice'); } } /** * Handle NOTICE messages. * * @since 0.1.0 */ function handleNotice(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; // Handle CTCP replies if (text.startsWith('\u0001') && text.endsWith('\u0001')) { handleCtcpReply(message, context, text); return; } logLine(context.ui.log, '! ' + text, 'notice'); } /** * Handle CTCP replies within NOTICE. * * @since 0.1.0 */ function handleCtcpReply( message: IrcMessage, context: MessageContext, text: string ): void { const from = (message.prefix || '').split('!')[0] || '???'; const ctcpReply = text.slice(1, -1); if (ctcpReply.startsWith('PING ')) { const pingTime = ctcpReply.slice(5); const responseTime = Date.now() - parseInt(pingTime); logLine( context.ui.log, `• PING reply from ${from}: ${responseTime}ms`, 'notice' ); } else if (ctcpReply.startsWith('VERSION ')) { const version = ctcpReply.slice(8); logLine(context.ui.log, `• ${from} version: ${version}`, 'notice'); } else { logLine( context.ui.log, `• CTCP reply from ${from}: ${ctcpReply}`, 'notice' ); } } /** * Handle TOPIC change messages. * * @since 0.1.0 */ function handleTopicChange(message: IrcMessage, context: MessageContext): void { const who = (message.prefix || '').split('!')[0] || '???'; const channel = message.args[0] || ''; const newTopic = message.args[1] || ''; logLine( context.ui.log, `• ${who} changed topic of ${channel} to: ${newTopic}`, 'sys' ); } // WHOIS responses function handleWhoisUser(message: IrcMessage, context: MessageContext): void { const [, , nick, user, host, , realname] = message.args; logLine( context.ui.log, `• ${nick} (${user}@${host}): ${realname}`, 'notice' ); } function handleWhoisServer(message: IrcMessage, context: MessageContext): void { const [, , nick, server, serverInfo] = message.args; logLine( context.ui.log, `• ${nick} using ${server} (${serverInfo})`, 'notice' ); } function handleWhoisIdle(message: IrcMessage, context: MessageContext): void { const [, , nick, idle, signon] = message.args; const idleTime = Math.floor(parseInt(idle) / 60); const signonDate = new Date(parseInt(signon) * 1000).toLocaleString(); logLine( context.ui.log, `• ${nick} idle ${idleTime} minutes, signed on ${signonDate}`, 'notice' ); } function handleWhoisEnd(message: IrcMessage, context: MessageContext): void { const [, , nick] = message.args; logLine(context.ui.log, `• End of WHOIS for ${nick}`, 'notice'); } // WHO responses function handleWhoReply(message: IrcMessage, context: MessageContext): void { const [ , , , /* channel */ user, host /* server */, , nick, flags, hopcountRealname, ] = message.args; const realname = hopcountRealname.split(' ').slice(1).join(' '); logLine( context.ui.log, `• ${nick} (${user}@${host}) [${flags}] ${realname}`, 'notice' ); } function handleWhoEnd(message: IrcMessage, context: MessageContext): void { const [, , target] = message.args; logLine(context.ui.log, `• End of WHO for ${target}`, 'notice'); } // LIST responses function handleListReply(message: IrcMessage, context: MessageContext): void { const [, , channel, userCount, topic] = message.args; logLine( context.ui.log, `• ${channel} (${userCount} users): ${topic || 'No topic'}`, 'notice' ); } function handleListEnd(message: IrcMessage, context: MessageContext): void { logLine(context.ui.log, '• End of channel list', 'notice'); } // TOPIC responses function handleTopicReply(message: IrcMessage, context: MessageContext): void { const [, , channel, topic] = message.args; logLine(context.ui.log, `• Topic for ${channel}: ${topic}`, 'notice'); } function handleNoTopic(message: IrcMessage, context: MessageContext): void { const [, , channel] = message.args; logLine(context.ui.log, `• No topic set for ${channel}`, 'notice'); } // NAMES responses function handleNamesReply(message: IrcMessage, context: MessageContext): void { // IRC 353 format: :server 353 nick = channel :names // args[0] = nick, args[1] = "=", args[2] = channel, args[3] = names const [, , channel, names] = message.args; logLine(context.ui.log, `• Users in ${channel}: ${names}`, 'notice'); // Parse the names list and update users state if (names && typeof names === 'string') { const userList = names .split(' ') .map((name) => { const trimmedName = name.trim(); if (!trimmedName) return null; // Parse IRC prefixes to determine user privileges // Supports standard IRC prefixes: ~ (owner), @ (op), % (half-op), + (voice), & (admin) // Note: This matches the first contiguous block of prefixes as per IRC standard const prefixMatch = trimmedName.match(/^([@+%&~]+)/); const prefixes = prefixMatch ? prefixMatch[1] : ''; const cleanNick = trimmedName.replace(/^[@+%&~]+/, ''); // Determine privileges based on prefixes (highest privilege takes precedence) const isOwner = prefixes.includes('~'); const isOp = prefixes.includes('@'); const isHalfOp = prefixes.includes('%'); const isVoice = prefixes.includes('+'); // All users in channel are considered online // In a more complete implementation, we could track away status separately const status = 'online'; return { nick: cleanNick, status: status as 'online' | 'away' | 'offline', isOwner, isOp, isHalfOp, isVoice, }; }) .filter( ( user ): user is { nick: string; status: 'online' | 'away' | 'offline'; isOwner: boolean; isOp: boolean; isHalfOp: boolean; isVoice: boolean; } => user !== null && user.nick.length > 0 ); context.setUsers?.(userList); context.debugLog( `Parsed ${userList.length} users: ${userList.map((u) => u?.nick || 'unknown').join(', ')}` ); } } function handleNamesEnd(message: IrcMessage, context: MessageContext): void { const [, , channel] = message.args; logLine(context.ui.log, `• End of names for ${channel}`, 'notice'); } // AWAY responses function handleNowAway(message: IrcMessage, context: MessageContext): void { logLine(context.ui.log, '• You are now away', 'sys'); } function handleUnaway(message: IrcMessage, context: MessageContext): void { logLine(context.ui.log, '• You are no longer away', 'sys'); } function handleAwayReply(message: IrcMessage, context: MessageContext): void { const [, , nick, awayMsg] = message.args; logLine(context.ui.log, `• ${nick} is away: ${awayMsg}`, 'notice'); } // TIME and VERSION responses function handleTimeReply(message: IrcMessage, context: MessageContext): void { const [, , server, time] = message.args; logLine(context.ui.log, `• Time on ${server}: ${time}`, 'notice'); } function handleVersionReply( message: IrcMessage, context: MessageContext ): void { const [, , version, server, comments] = message.args; logLine( context.ui.log, `• ${server} version: ${version} ${comments || ''}`, 'notice' ); } // MOTD responses function handleMotdStart(message: IrcMessage, context: MessageContext): void { const [, , server] = message.args; logLine(context.ui.log, `• Message of the day for ${server}:`, 'notice'); } function handleMotdReply(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, ` ${text}`, 'notice'); } function handleNoMotd(message: IrcMessage, context: MessageContext): void { const [, , server] = message.args; logLine(context.ui.log, `• ${server} has no message of the day`, 'notice'); } // ADMIN responses function handleAdminMe(message: IrcMessage, context: MessageContext): void { const [, , server, info] = message.args; logLine(context.ui.log, `• Administrative info for ${server}:`, 'notice'); if (info) logLine(context.ui.log, ` ${info}`, 'notice'); } function handleAdminLoc1(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, ` Location: ${text}`, 'notice'); } function handleAdminLoc2(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, ` Institution: ${text}`, 'notice'); } function handleAdminEmail(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, ` Contact: ${text}`, 'notice'); } // INFO responses function handleInfoReply(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, ` ${text}`, 'notice'); } function handleEndOfInfo(message: IrcMessage, context: MessageContext): void { logLine(context.ui.log, '• End of server info', 'notice'); } // STATS responses function handleStatsLinkInfo( message: IrcMessage, context: MessageContext ): void { const [, , linkname, sentmsg, sentbytes, recvmsg, recvbytes] = message.args; logLine( context.ui.log, `• Link: ${linkname} - Sent: ${sentmsg}/${sentbytes} Recv: ${recvmsg}/${recvbytes}`, 'notice' ); } function handleStatsCommands( message: IrcMessage, context: MessageContext ): void { const [, , command, count, bytecount] = message.args; logLine( context.ui.log, `• Command: ${command} - Count: ${count} Bytes: ${bytecount}`, 'notice' ); } function handleStatsCLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• C-Line: ${text}`, 'notice'); } function handleStatsNLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• N-Line: ${text}`, 'notice'); } function handleStatsILine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• I-Line: ${text}`, 'notice'); } function handleStatsKLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• K-Line: ${text}`, 'notice'); } function handleStatsYLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• Y-Line: ${text}`, 'notice'); } function handleStatsLLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• L-Line: ${text}`, 'notice'); } function handleStatsUptime(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• Server uptime: ${text}`, 'notice'); } function handleStatsOLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• O-Line: ${text}`, 'notice'); } function handleStatsHLine(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• H-Line: ${text}`, 'notice'); } function handleEndOfStats(message: IrcMessage, context: MessageContext): void { const [, , statstype] = message.args; logLine(context.ui.log, `• End of stats ${statstype}`, 'notice'); } // LINKS responses function handleLinksReply(message: IrcMessage, context: MessageContext): void { const [, , , server, hopcount, info] = message.args; logLine( context.ui.log, `• ${server} (${hopcount} hops): ${info}`, 'notice' ); } function handleEndOfLinks(message: IrcMessage, context: MessageContext): void { const [, , mask] = message.args; logLine( context.ui.log, `• End of links${mask ? ` for ${mask}` : ''}`, 'notice' ); } // LUSERS responses function handleLusersClient( message: IrcMessage, context: MessageContext ): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• ${text}`, 'notice'); } function handleLusersOp(message: IrcMessage, context: MessageContext): void { const [, , count] = message.args; logLine(context.ui.log, `• ${count} IRC operators online`, 'notice'); } function handleLusersUnknown( message: IrcMessage, context: MessageContext ): void { const [, , count] = message.args; logLine(context.ui.log, `• ${count} unknown connections`, 'notice'); } function handleLusersChannels( message: IrcMessage, context: MessageContext ): void { const [, , count] = message.args; logLine(context.ui.log, `• ${count} channels formed`, 'notice'); } function handleLusersMe(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; logLine(context.ui.log, `• ${text}`, 'notice'); } // USERHOST and ISON responses function handleUserhostReply( message: IrcMessage, context: MessageContext ): void { const text = message.args[message.args.length - 1] || ''; const hosts = text.split(' '); for (const host of hosts) { if (host.trim()) { logLine(context.ui.log, `• ${host}`, 'notice'); } } } function handleIsonReply(message: IrcMessage, context: MessageContext): void { const text = message.args[message.args.length - 1] || ''; const nicks = text.trim(); if (nicks) { logLine(context.ui.log, `• Online: ${nicks}`, 'notice'); } else { logLine( context.ui.log, '• No users from the list are online', 'notice' ); } }