class IRCClient { constructor() { this.ws = null; this.nickname = ''; this.channels = new Set(); this.callbacks = { message: () => {}, join: () => {}, part: () => {}, connected: () => {}, error: () => {}, channelUsers: () => {}, topic: () => {}, serverInfo: () => {}, channelList: () => {} }; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.channelUsers = new Map(); this.channelTopics = new Map(); this.privateMessages = new Map(); this.serverInfo = { network: 'Libera Chat', users: 0, channels: 0, operators: 0 }; this.pingInterval = null; this.lastPing = Date.now(); this.connectionState = 'disconnected'; this.globalChannels = new Set(); } connect(nickname) { this.nickname = nickname; this.connectionState = 'connecting'; try { this.ws = new WebSocket('wss://web.libera.chat/webirc/websocket/'); this.pingInterval = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { this.send('PING :' + Date.now()); } }, 30000); this.ws.onopen = () => { this.connectionState = 'registering'; this.reconnectAttempts = 0; this.send(`NICK ${this.nickname}`); this.send(`USER ${this.nickname} 0 * :${this.nickname}`); this.send('LUSERS'); this.send('LIST'); }; this.ws.onmessage = (event) => this.handleMessage(event.data); this.ws.onerror = (error) => { console.error('WebSocket error:', error); let errorMessage = 'Connection error occurred'; if (error.message) { errorMessage = error.message; } else if (error.code) { errorMessage = `Error code: ${error.code}`; } this.callbacks.error(errorMessage); }; this.ws.onclose = (event) => { let closeMessage = 'Connection closed'; if (event.code !== 1000) { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; closeMessage = `Connection lost. Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`; this.callbacks.error(closeMessage); setTimeout(() => this.connect(this.nickname), 2000); return; } closeMessage = 'Connection closed unexpectedly. Please try reconnecting.'; } this.callbacks.error(closeMessage); }; } catch (error) { this.connectionState = 'error'; console.error('Connection error:', error); this.callbacks.error('Failed to establish connection: ' + error.message); } } disconnect() { if (this.pingInterval) { clearInterval(this.pingInterval); } if (this.ws) { this.ws.close(1000, 'User initiated disconnect'); } this.connectionState = 'disconnected'; } send(message) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(message + '\r\n'); } } join(channel) { if (!channel.startsWith('#')) channel = '#' + channel; this.send(`JOIN ${channel}`); this.channels.add(channel); } part(channel) { this.send(`PART ${channel}`); this.channels.delete(channel); } sendMessage(channel, message) { this.send(`PRIVMSG ${channel} :${message}`); } getChannelUsers(channel) { return Array.from(this.channelUsers.get(channel) || new Set()); } getChannelTopic(channel) { return this.channelTopics.get(channel) || ''; } sendPrivateMessage(target, message) { this.send(`PRIVMSG ${target} :${message}`); if (!this.privateMessages.has(target)) { this.privateMessages.set(target, []); } this.privateMessages.get(target).push({ from: this.nickname, message, timestamp: Date.now() }); } requestChannelInfo(channel) { this.send(`MODE ${channel}`); this.send(`WHO ${channel}`); this.send(`TOPIC ${channel}`); } handleMessage(data) { const lines = data.split('\r\n'); for (const line of lines) { if (!line) continue; if (line.startsWith('PING')) { this.send('PONG' + line.substring(4)); this.lastPing = Date.now(); continue; } const match = line.match(/^(?::([\w.]+) )?([\w]+)(?: (?!:)(.+?))?(?: :(.+))?$/); if (!match) continue; const [, prefix, command, params = '', trailing] = match; const args = params.split(' ').filter(arg => arg); if (trailing) args.push(trailing); switch (command) { case '001': this.connectionState = 'connected'; this.callbacks.connected(); break; case '353': { const channel = args[2]; const users = args[3].split(' '); if (!this.channelUsers.has(channel)) { this.channelUsers.set(channel, new Set()); } users.forEach(user => this.channelUsers.get(channel).add(user)); this.callbacks.channelUsers?.({ channel, users: Array.from(this.channelUsers.get(channel)) }); break; } case '332': { const topicChannel = args[1]; const topic = args[2]; this.channelTopics.set(topicChannel, topic); this.callbacks.topic?.({ channel: topicChannel, topic }); break; } case '333': break; case '266': { const match = trailing.match(/(\d+)/); if (match) { this.serverInfo.users = parseInt(match[1]); this.callbacks.serverInfo?.(this.serverInfo); } break; } case '322': { const [channel, userCount, topic] = args; this.globalChannels.add(channel); break; } case '323': { this.callbacks.channelList?.(Array.from(this.globalChannels)); break; } case 'PRIVMSG': { const [target, message] = [args[0], args[1]]; const nick = prefix.split('!')[0]; this.callbacks.message({ from: nick, target, message, isAction: message.startsWith('\u0001ACTION') && message.endsWith('\u0001'), timestamp: Date.now() }); break; } case 'JOIN': this.callbacks.join({ channel: args[0], nick: prefix.split('!')[0] }); this.requestChannelInfo(args[0]); break; case 'PART': this.callbacks.part({ channel: args[0], nick: prefix.split('!')[0] }); break; case 'QUIT': this.channelUsers.forEach((users, channel) => { if (users.delete(prefix.split('!')[0])) { this.callbacks.channelUsers?.({ channel, users: Array.from(users) }); } }); break; case 'NICK': { const oldNick = prefix.split('!')[0]; const newNick = args[0]; if (oldNick === this.nickname) { this.nickname = newNick; } this.channelUsers.forEach((users, channel) => { if (users.has(oldNick)) { users.delete(oldNick); users.add(newNick); this.callbacks.channelUsers?.({ channel, users: Array.from(users) }); } }); break; } } } } on(event, callback) { if (this.callbacks.hasOwnProperty(event)) { this.callbacks[event] = callback; } } onChannelUsers(callback) { this.callbacks.channelUsers = callback; } onTopic(callback) { this.callbacks.topic = callback; } onServerInfo(callback) { this.callbacks.serverInfo = callback; } onChannelList(callback) { this.callbacks.channelList = callback; } }