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