nova-irc
Version:
Easy-to-use IRC client
653 lines (562 loc) • 23.6 kB
JavaScript
const net = require('net');
const tls = require('tls');
const EventEmitter = require('events');
// Extra modules
const novaCodes = require('./nova-irc-codes');
var novaColors = require('./nova-strip-colors');
class novaIRC extends EventEmitter {
constructor() {
super();
this.client = null;
this.nickname = null;
this.username = null;
this.password = null;
this.lastPingTime = Date.now();
this.messageColor = null;
this.pingInterval = 8 * 60 * 1000; // 8 minutes in milliseconds
this.commandQueue = [];
this.isProcessingQueue = false;
this.floodProtectionDelay = 1000;
this.rejoinAttempts = {};
this.rejoinLimit = 3;
this.rejoinDelay = 5000;
}
// Conect to server (SSL is opcional, false by default)
connect(options = {}) {
const {
server = 'localhost',
port = 6667,
ssl = false,
removeColors = true,
messageColor = null,
rejectUnauthorized = false,
rejoinLimit = 3,
rejoinDelay = 5000,
maxLineLength = 350
} = options;
// Conection type, based on SLL
const connectionOptions = ssl ? {
rejectUnauthorized
} : {};
this.client = ssl ?
tls.connect(port, server, connectionOptions) :
net.connect(port, server);
// Define variables
this.messageColor = messageColor;
this.rejoinLimit = rejoinLimit;
this.rejoinDelay = rejoinDelay;
this.maxLineLength = maxLineLength;
// Connect to server
this.client.on('connect', () => {
this.emit('connected');
// Identifies Nick and Username
this.sendRaw(`NICK ${this.nickname}`);
this.sendRaw(`USER ${this.username} 0 * :Nova IRC`);
// If it has Password, automatically sends IDENTIFY
if (this.password) {
this.sendRaw(`PRIVMSG NickServ :IDENTIFY ${this.password}`);
}
});
// Check for a ping request, if it takes too long, sends a pong.
this.startPingChecker();
this.client.on('data', (data) => {
const messages = data.toString().split('\r\n'); // Split into individual messages
messages.forEach((message) => {
if (!message) return;
this.emit('raw', message);
const parsedMessage = this.parseMessage(message, removeColors);
if (!parsedMessage) {
console.error("Failed to parse message:", message);
return;
}
// Command Message
const {
command,
params,
commandType
} = parsedMessage;
switch (command) {
case 'rpl_yourhost':
case 'rpl_created':
case 'rpl_luserclient':
case 'rpl_luserop':
case 'rpl_luserchannels':
case 'rpl_luserme':
case 'rpl_localusers':
case 'rpl_globalusers':
case 'rpl_statsconn':
case 'rpl_luserunknown':
case 'rpl_welcome':
case 'rpl_myinfo':
case 'rpl_isupport':
case 'rpl_endofmotd':
case 'rpl_endofbanlist':
case 'rpl_endofnames':
case '396':
case '042':
case '378':
case '330':
// Ignore these commands
break;
case 'rpl_motd':
case 'rpl_motdstart':
case 'rpl_endofmotd':
this.emit('motd', {
user: params[0],
content: params[1],
raw: message,
});
break;
case 'rpl_whoisuser':
this.emit('whoisUser', {
nick: params[1],
username: params[2],
host: params[3],
realName: params[5],
raw: message,
});
break;
case 'rpl_whoisserver':
this.emit('whoisServer', {
nick: params[1],
server: params[2],
serverInfo: params[3],
raw: message,
});
break;
case 'rpl_whoischannels':
this.emit('whoisChannels', {
nick: params[1],
channels: params[2],
raw: message,
});
break;
case 'rpl_whoisidle':
this.emit('whoisIdle', {
nick: params[1],
idleTime: params[2],
raw: message,
});
break;
case 'rpl_whoisoperator':
this.emit('whoisOperator', {
nick: params[1],
message: params[2],
raw: message,
});
break;
case 'rpl_endofwhois':
this.emit('whoisEnd', {
nick: params[1],
raw: message,
});
break;
case 'rpl_namreply':
const channel = params[2];
const names = params[3].split(' ');
this.emit('names', {
channel,
names,
raw: message
});
break;
case 'rpl_banlist':
const [channelban, banMask, setBy, timestamp] = params;
this.emit('banlist', {
channelban,
banMask,
setBy,
timestamp
});
break;
case 'PING':
this.handlePing(params, message);
break;
case 'PRIVMSG':
const sender = parsedMessage.nick;
const target = params[0];
const content = params[1];
// General message event
this.emit('message', {
sender,
target,
content,
raw: message,
});
// Check if it is Private or in Channel
if (target === this.nickname) {
this.emit('directMessage', {
sender,
content,
raw: message
});
}
else {
this.emit('channelMessage', {
sender,
channel: target,
content,
raw: message
});
}
break;
case 'NOTICE':
this.emit('notice', {
target: params[0],
content: params[1],
raw: message,
});
break;
case 'JOIN':
this.emit('join', {
user: parsedMessage.nick,
channel: params[0],
raw: message,
});
break;
case 'PART':
this.emit('part', {
user: parsedMessage.nick,
channel: params[0],
raw: message,
});
break;
case 'QUIT':
this.emit('quit', {
user: parsedMessage.nick,
host: parsedMessage.host || '',
reason: params[0] || '',
raw: message,
});
break;
case 'MODE':
const modeChar = params[1]?.charAt(0);
const modeEvent = modeChar === '+' ? '+mode' : modeChar === '-' ? '-mode' : 'mode';
this.emit(modeEvent, {
user: parsedMessage.nick,
mode: params[1],
affected: params[2],
raw: message,
});
break;
case 'KICK':
this.emit('kick', {
kicker: parsedMessage.nick,
host: parsedMessage.host,
channelKick: params[0],
kickedUser: params[1],
reason: params[2] || '',
raw: message,
});
// Handle auto-rejoin if the bot is the kicked user
if (params[1] === this.nickname) {
const channel = params[0];
// Emits event, it might be usefull to know if bot was kicked.
this.emit('botKicked');
// If the bot is kicked, try to rejoin after a delay
if (!this.rejoinAttempts[channel]) {
this.rejoinAttempts[channel] = 0;
}
if (this.rejoinAttempts[channel] < this.rejoinLimit) {
this.rejoinAttempts[channel]++;
// Delay rejoin attempt
setTimeout(() => {
this.rejoinChannel(channel);
}, this.rejoinDelay);
}
}
break;
case 'err_nosuchnick':
this.emit('error', new Error(), parsedMessage);
this.emit('whoisError', {
error: "err_nosuchnick",
code: 401
});
break;
case '307':
// Some old servers send this numeric to indicate a registered nick
this.emit('whoisRegistered', { nick: params[1], registered: params[2] === 'is a registered nick' });
break;
case 'ERROR':
this.emit('error', new Error(), parsedMessage);
break;
default:
if(commandType == 'error')
{
this.emit('error', new Error(), parsedMessage);
} else {
this.emit('unknown', { command, raw: message });
}
}
});
});
this.client.on('close', () => {
this.emit('disconnected');
});
this.client.on('error', (err) => {
this.emit('error', new Error(`Connection error: ${err.message}`));
});
}
// Trata o Ping e envia automaticamente o PING
handlePing(params, rawMessage) {
const server = params[0] || this.parseMessage(rawMessage).trailing; // Fallback to trailing
this.sendRawImmediate(`PONG ${server}`); // Respond to PING to maintain connection
this.emit('ping', {
server,
raw: rawMessage
});
this.lastPingTime = Date.now();
}
// Method to rejoin a channel
rejoinChannel(channel) {
console.log(`Attempting to rejoin channel: ${channel}`);
this.sendRaw(`JOIN ${channel}`);
this.rejoinAttempts[channel] = 0; // Reset attempts on success
}
// Check if server is silent for too long, if it is, sends a pong.
startPingChecker() {
setInterval(() => {
const now = Date.now();
const timeSinceLastPing = now - this.lastPingTime;
if (timeSinceLastPing > this.pingInterval) {
console.warn('Servidor não enviou PING durante 8 minutos, enviar um PONG para manter vivo.');
this.sendRaw('PONG :KeepAlive');
}
}, this.pingInterval / 2); // Check every minute
}
parseMessage(line, removeColors) {
if (!line) return null; // Handle empty input
if (removeColors) {
line = novaColors.stripColorsAndStyle(line);
}
const message = {};
let match;
// Parse prefix
match = line.match(/^:([^ ]+) +/);
if (match) {
message.prefix = match[1];
line = line.substring(match[0].length); // More efficient than replace
const prefixMatch = message.prefix.match(/^([_a-zA-Z0-9~[\]\\`^{}|-]*)(!([^@]+)@(.*))?$/);
if (prefixMatch) {
message.nick = prefixMatch[1];
message.user = prefixMatch[3] || null;
message.host = prefixMatch[4] || null;
} else {
message.server = message.prefix;
}
}
// Parse command
match = line.match(/^([^ ]+)\s*/);
if (!match) return null; // Handle invalid format
message.rawCommand = match[1];
message.command = novaCodes[message.rawCommand]?.name || message.rawCommand;
message.commandType = novaCodes[message.rawCommand]?.type || 'normal';
line = line.substring(match[0].length); // Efficient removal
message.params = [];
// Parse parameters
match = line.match(/(.*?)\s*:(.*)/);
if (match) {
const middle = match[1].trim();
if (middle) message.params = middle.split(/\s+/);
message.params.push(match[2]); // Trailing param
} else if (line) {
message.params = line.trim().split(/\s+/);
}
return message;
}
// Sets identification credentials
setCredentials(nickname, username, password = null) {
if(nickname == "" || username == "")
{
this.emit('error', new Error('Nickname and username required.'));
return false;
}
this.nickname = nickname;
this.username = username;
this.password = password;
}
// Joins Channel
joinChannel(channel, password = '') {
setTimeout(() => {
this.sendRaw(`JOIN ${channel} ${password}`);
}, "2000");
}
// Sends a message to a user or channel
sendMessage(target, message, color = null) {
if (this.messageColor != null) {
color = this.messageColor;
}
const COLORS = {
white: '0', black: '1', blue: '2', green: '3', red: '4',
brown: '5', purple: '6', orange: '7', yellow: '8', light_green: '9',
cyan: '10', light_cyan: '11', light_blue: '12', pink: '13',
grey: '14', light_grey: '15',
};
const MAX_LINE_LENGTH = this.maxLineLength
const RESET = '\x03';
let colorCode = '';
if (color && COLORS[color.toLowerCase()]) {
colorCode = `${RESET}${COLORS[color.toLowerCase()]}`;
}
function splitMessage(message) {
let words = message.split(' ');
let lines = [];
let currentLine = '';
words.forEach((word) => {
let testLine = currentLine.length ? `${currentLine} ${word}` : word;
if (testLine.length + colorCode.length + RESET.length <= MAX_LINE_LENGTH) {
currentLine = testLine;
} else {
if (currentLine) {
lines.push(currentLine);
}
currentLine = word;
}
});
if (currentLine) {
lines.push(currentLine);
}
return lines;
}
const messageParts = splitMessage(message);
messageParts.forEach((part, index) => {
let formattedPart = colorCode ? `${colorCode}${part}${RESET}` : part;
this.sendRaw(`PRIVMSG ${target} :${formattedPart}`);
});
}
// Sends RAW message to the server
sendRaw(command) {
this.commandQueue.push(command);
this.processQueue();
}
// Bypass FloodProtection for a immediate message to the server
sendRawImmediate(command) {
if (this.client) {
this.client.write(`${command}\r\n`);
}
}
// Queue list processing
processQueue() {
if (this.isProcessingQueue) return;
this.isProcessingQueue = true;
const interval = setInterval(() => {
if (this.commandQueue.length === 0) {
clearInterval(interval);
this.isProcessingQueue = false;
return;
}
const command = this.commandQueue.shift();
if (this.client) {
this.client.write(`${command}\r\n`);
}
}, this.floodProtectionDelay);
}
// Clear queue
clearQueue() {
this.commandQueue = [];
}
// Disconects from a specific channel
part(channel, message = '') {
const partMessage = message ? ` :${message}` : '';
this.sendRaw(`PART ${channel}${partMessage}`);
}
// Gets all nicks from a chanell
names(channel) {
this.sendRaw(`NAMES ${channel}`);
}
// Obtem todos os nicks num canal
kick(channel, user, reason = '') {
const formattedReason = reason ? ` :${reason}` : '';
this.sendRaw(`KICK ${channel} ${user}${formattedReason}`);
}
// Add a BAN to the list
ban(channel, mask) {
this.sendRaw(`MODE ${channel} +b ${mask}`);
}
// Disconnects from server
disconnect(message = 'Goodbye!') {
this.sendRaw(`QUIT :${message}`);
this.client.end();
}
// Gets a BAN List from a channel
banlist(channel) {
this.sendRaw(`MODE ${channel} +b`);
}
// Get a complete WHOIS information form a specific user
whois(nickname) {
return new Promise((resolve, reject) => {
const whoisData = {
nick: nickname,
username: null,
host: null,
realName: null,
server: null,
serverInfo: null,
idleTime: null,
channels: [],
isOperator: false,
isRegistered: false, // <-- Add registration status
};
const handleWhoisUser = (data) => {
whoisData.username = data.username;
whoisData.host = data.host;
whoisData.realName = data.realName;
};
const handleWhoisServer = (data) => {
whoisData.server = data.server;
whoisData.serverInfo = data.serverInfo;
};
const handleWhoisChannels = (data) => {
whoisData.channels = data.channels.split(' ');
};
const handleWhoisIdle = (data) => {
whoisData.idleTime = parseInt(data.idleTime, 10);
};
const handleWhoisOperator = () => {
whoisData.isOperator = true;
};
const handleWhoisRegistered = (data) => {
if (data.nick === nickname) { // Ensure it's for the correct user
whoisData.isRegistered = data.registered;
}
};
const handleWhoisEnd = () => {
cleanup();
resolve(whoisData);
};
const handleWhoisError = (data) => {
if (data.code === 401 || data.error === "ERR_NOSUCHNICK") {
cleanup();
reject(new Error(`No such nickname: ${nickname}`));
}
};
const cleanup = () => {
this.off('whoisUser', handleWhoisUser);
this.off('whoisServer', handleWhoisServer);
this.off('whoisChannels', handleWhoisChannels);
this.off('whoisIdle', handleWhoisIdle);
this.off('whoisOperator', handleWhoisOperator);
this.off('whoisRegistered', handleWhoisRegistered); // Remove registration listener
this.off('whoisEnd', handleWhoisEnd);
this.off('whoisError', handleWhoisError);
};
// Listen for WHOIS response events
this.on('whoisUser', handleWhoisUser);
this.on('whoisServer', handleWhoisServer);
this.on('whoisChannels', handleWhoisChannels);
this.on('whoisIdle', handleWhoisIdle);
this.on('whoisOperator', handleWhoisOperator);
this.on('whoisRegistered', handleWhoisRegistered); // Listen for registration status
this.on('whoisEnd', handleWhoisEnd);
this.on('whoisError', handleWhoisError);
// Send WHOIS command
try {
this.sendRaw(`WHOIS ${nickname}`);
} catch (err) {
cleanup();
reject(err);
}
});
}
}
module.exports = novaIRC;