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