Initial commit

This commit is contained in:
Evert Prants 2022-09-23 20:38:41 +03:00
commit ea7f81d9fe
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
23 changed files with 1385 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/node_modules/
/lib/

3
.npmignore Normal file
View File

@ -0,0 +1,3 @@
src/
.prettierrc
.vscode

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all"
}

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"editor.formatOnSave": true,
"files.insertFinalNewline": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.validate": [
"typescript",
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

72
package-lock.json generated Normal file
View File

@ -0,0 +1,72 @@
{
"name": "@icynet/irclib",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@icynet/irclib",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@types/node": "^18.7.18",
"prettier": "^2.7.1",
"typescript": "^4.8.3"
}
},
"node_modules/@types/node": {
"version": "18.7.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz",
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==",
"dev": true
},
"node_modules/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
},
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/typescript": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz",
"integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
}
},
"dependencies": {
"@types/node": {
"version": "18.7.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz",
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==",
"dev": true
},
"prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
"dev": true
},
"typescript": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz",
"integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==",
"dev": true
}
}
}

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "@icynet/irclib",
"version": "1.0.0",
"description": "IRC library written in TypeScript",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"prepublish": "npm run build"
},
"author": "Evert Prants",
"license": "ISC",
"devDependencies": {
"@types/node": "^18.7.18",
"prettier": "^2.7.1",
"typescript": "^4.8.3"
}
}

22
src/bot.ts Normal file
View File

@ -0,0 +1,22 @@
import { format } from 'util';
import { IRCSocketConnector } from './connector/net.connector';
import { IRCConnectionWrapper } from './irc';
import { IIRCOptions } from './types/irc.interfaces';
export class IRCBot extends IRCConnectionWrapper {
constructor(options: IIRCOptions) {
super(options, IRCSocketConnector);
}
send(to: string, message: string, ...args: any[]) {
this.write('PRIVMSG %s :%s', to, format(message, ...args));
}
notice(to: string, message: string, ...args: any[]) {
this.write('NOTICE %s :%s', to, format(message, ...args));
}
nick(newNick: string) {
this.write('NICK %s', newNick);
}
}

View File

@ -0,0 +1,83 @@
import net, { Socket } from 'net';
import tls, { TLSSocket } from 'tls';
import { formatWithOptions } from 'util';
import { IRCConnector } from '../types/impl.interface';
import { SimpleEventEmitter } from '../utility/simple-event-emitter';
export class IRCSocketConnector
extends SimpleEventEmitter
implements IRCConnector
{
connected = false;
private socket?: Socket | TLSSocket;
constructor(
public secure: boolean,
public host: string,
public port?: number,
) {
super();
}
connect(): Promise<void> {
const opts = { host: this.host, port: this.port || 6667 };
return new Promise((resolve, reject) => {
const onConnect = () => {
this.connected = true;
resolve();
};
try {
if (this.secure) {
this.socket = tls.connect(opts, onConnect);
} else {
this.socket = net.connect(opts, onConnect);
}
} catch (e: unknown) {
return reject(e);
}
this.handle();
});
}
async destroy(): Promise<void> {
this.connected = false;
this.socket?.destroy();
this.socket = undefined;
}
write(format: string, ...args: any[]): void {
this.socket?.write(
formatWithOptions({ colors: false }, format, ...args) + '\r\n',
);
}
private handle() {
this.socket?.setDefaultEncoding('utf-8');
let buffer: string = '';
this.socket?.on('data', (chunk: string) => {
buffer += chunk;
const data = buffer.split('\r\n');
buffer = data.pop() || '';
data.forEach((line: string) => {
if (line.indexOf('PING') === 0) {
this.socket?.write('PONG' + line.substring(4) + '\r\n');
return;
}
this.emit('data', line);
});
});
this.socket?.on('error', (err) => this.emit('error', err));
this.socket?.on('close', (err) => {
this.connected = false;
this.socket = undefined;
this.emit('close', err);
});
}
}

View File

@ -0,0 +1,79 @@
import { formatWithOptions } from 'util';
import { IRCConnector } from '../types/impl.interface';
import { SimpleEventEmitter } from '../utility/simple-event-emitter';
export class IRCWebSocketConnector
extends SimpleEventEmitter
implements IRCConnector
{
connected = false;
private socket?: WebSocket;
constructor(
public secure: boolean,
public host: string,
public port?: number,
) {
super();
}
connect(): Promise<void> {
const url = `ws${this.secure ? 's' : ''}://${this.host}:${
this.port || 6667
}`;
return new Promise((resolve, reject) => {
const onConnect = () => {
this.connected = true;
resolve();
};
try {
this.socket = new WebSocket(url);
} catch (e: unknown) {
return reject(e);
}
this.socket?.addEventListener('open', onConnect);
this.handle();
});
}
async destroy(): Promise<void> {
this.connected = false;
this.socket?.close();
this.socket = undefined;
}
write(format: string, ...args: any[]): void {
this.socket?.send(
formatWithOptions({ colors: false }, format, ...args) + '\r\n',
);
}
private handle() {
let buffer: string = '';
this.socket?.addEventListener('message', (event) => {
const chunk = event.data.toString();
buffer += chunk;
const data = buffer.split('\r\n');
buffer = data.pop() || '';
data.forEach((line: string) => {
if (line.indexOf('PING') === 0) {
this.socket?.send('PONG' + line.substring(4) + '\r\n');
return;
}
this.emit('data', line);
});
});
this.socket?.addEventListener('error', (err) => this.emit('error', err));
this.socket?.addEventListener('close', (err) => {
this.connected = false;
this.socket = undefined;
this.emit('close', err);
});
}
}

View File

@ -0,0 +1,56 @@
import { IRCBot } from '../bot';
import { NickServValidator } from '../utility/nickserv-validator';
const bot = new IRCBot({
host: 'icynet.eu',
nick: 'MyTestBot',
channels: ['#squeebot'],
nickserv: {
enabled: true,
command: 'STATUS',
},
});
const nickserv = new NickServValidator(bot);
bot.on('authenticated', () => {
console.log('Successful connection!');
});
bot.on('server-supports', (supported) => {
console.log(supported);
});
// bot.on('line', console.log);
bot.on('message', ({ message, to, nickname }) => {
console.log(`[${to}] ${nickname}: ${message}`);
if (message.startsWith('!test')) {
nickserv
.getNickStatus(nickname)
.then((valid) =>
bot.send(
to,
`Hello, %s! ${
valid ? 'You are logged in.' : 'You are not logged in.'
}`,
nickname,
),
);
}
if (message.startsWith('!whois')) {
bot.whois(nickname).then(console.log);
}
if (message.startsWith('!ping')) {
bot.getPing().then((res) => bot.send(to, `Pong: ${res / 1000}s`));
}
});
bot.on('disconnect', console.log);
bot.on('names', console.log);
bot.connect();

12
src/index.ts Normal file
View File

@ -0,0 +1,12 @@
export * from './types/irc.interfaces';
export * from './types/impl.interface';
export * from './utility/collector';
export * from './utility/nickserv-validator';
export * from './utility/parser';
export * from './utility/simple-event-emitter';
export * from './utility/truncate';
export * from './utility/user-mapper';
export * from './utility/whois-parser';
export * from './irc';

519
src/irc.ts Normal file
View File

@ -0,0 +1,519 @@
import {
IRCCommunicator,
IRCConnector,
IRCConnectorConstructor,
} from './types/impl.interface';
import {
IIRCLine,
IIRCOptions,
INickStore,
IQueue,
} from './types/irc.interfaces';
import { Collector, WhoisCollector } from './utility/collector';
import { parse } from './utility/parser';
import { SimpleEventEmitter } from './utility/simple-event-emitter';
import { parseWhois, WhoisResponse } from './utility/whois-parser';
const encodeBase64 = (input: string) => {
if (window !== undefined && btoa !== undefined) {
return btoa(input);
} else if (Buffer !== undefined) {
return Buffer.from(input).toString('base64');
}
return input;
};
export class IRCConnectionWrapper
extends SimpleEventEmitter
implements IRCCommunicator
{
public channels: string[] = [];
public queue: IQueue[] = [];
public nickservStore: { [key: string]: INickStore } = {};
public authenticated = false;
public serverData: { [key: string]: any } = {
name: '',
supportedModes: {},
serverSupports: {},
};
public connection?: IRCConnector;
private _supportsDone = false;
private _lastLineWasSupports = false;
constructor(
public options: IIRCOptions,
public connector: IRCConnectorConstructor,
) {
super();
if (!options.username) {
options.username = options.nick;
}
this.handlers();
}
write(format: string, ...args: any[]): void {
this.connection?.write(format, ...args);
}
private handlers(): void {
this.on('authenticated', () => {
if (this.options.channels?.length) {
this.joinMissingChannels(this.options.channels);
}
});
}
private authenticate(): void {
if (this.options.sasl) {
this.write('CAP REQ :sasl');
}
if (this.options.password && !this.options.sasl) {
this.write('PASS %s', this.options.password);
}
this.write(
'USER %s 8 * :%s',
this.options.username,
this.options.realname || 'realname',
);
this.write('NICK %s', this.options.nick);
}
private joinMissingChannels(arr: string[]): void {
if (arr) {
for (const i in arr) {
let chan = arr[i];
if (chan.indexOf('#') !== 0) {
chan = '#' + chan;
}
if (this.channels.indexOf(chan) === -1) {
this.write('JOIN %s', chan);
}
}
}
}
private handleServerLine(line: IIRCLine): void {
if (this.queue.length) {
let skipHandling = false;
const afterModifyQueue: IQueue[] = [];
this.queue.forEach((entry) => {
if (!entry.untracked) {
if (entry.await && line.command === entry.await) {
if (
((entry.from &&
line.user.nickname.toLowerCase() ===
entry.from.toLowerCase()) ||
!entry.from) &&
entry.do
) {
skipHandling = true;
entry.do(line, entry.buffer);
return;
}
}
if (
entry.additional &&
entry.additional.includes(line.command) &&
entry.digest
) {
skipHandling = true;
entry.digest(line);
}
}
afterModifyQueue.push(entry);
});
this.queue = afterModifyQueue;
if (skipHandling) {
return;
}
}
if (
this._lastLineWasSupports &&
!this._supportsDone &&
line.command !== '005'
) {
this._lastLineWasSupports = false;
this._supportsDone = true;
this.emit('supported-modes', this.serverData.supportedModes);
this.emit('server-supports', this.serverData.serverSupports);
}
switch (line.command.toLowerCase()) {
case 'cap':
if (
line.trailing === 'sasl' &&
line.arguments?.[1] === 'ACK' &&
!this.authenticated
) {
this.write('AUTHENTICATE PLAIN');
}
break;
case '+':
case ':+': {
if (this.authenticated) {
return;
}
const authline = encodeBase64(
this.options.nick +
'\x00' +
this.options.username +
'\x00' +
this.options.password,
);
this.write('AUTHENTICATE %s', authline);
break;
}
case '353': {
const isUpQueued = this.queue.find(
(item) => item.await === '366' && item.from === line.arguments![2],
);
if (isUpQueued) {
isUpQueued.buffer.push(line);
} else {
this.queue.push({
untracked: true,
from: line.arguments![2],
await: '366',
additional: ['353'],
buffer: [line],
do: (bline, data) => {
this.emit('names', {
channel: bline.arguments![1],
list: [
...data.map((cline: IIRCLine) =>
(cline.trailing || '').split(' '),
),
].flat(),
});
},
});
}
break;
}
case '366': {
const isUpQueued = this.queue.find(
(item) => item.await === '366' && item.from === line.arguments![1],
);
if (isUpQueued) {
isUpQueued.do(line, isUpQueued.buffer);
this.queue.splice(this.queue.indexOf(isUpQueued), 1);
}
break;
}
case '433':
const newNick = this.options.nick + '_';
this.write('NICK %s', newNick);
this.options.nick = newNick;
break;
case '904':
this.emit('error', {
error: new Error(line.trailing),
fatal: true,
});
break;
case '903':
this.write('CAP END');
break;
case 'notice':
case 'privmsg':
if (!line.user.nickname || line.user.nickname === '') {
return;
}
this.emit('message', {
type: line.command.toLowerCase(),
message: line.trailing,
to: line.arguments?.[0],
nickname: line.user.nickname,
raw: line,
});
break;
case '001':
if (!this.authenticated) {
this.serverData.name = line.user.hostname;
this.authenticated = true;
// Set nick to what the server actually thinks is our nick
this.options.nick = line.arguments?.[0] || this.options.nick;
this.authenticated = true;
this.emit('authenticated', true);
// Send a whois request for self in order to reliably fetch hostname of self
this.write('WHOIS %s', this.options.nick);
}
break;
case '005': {
this._lastLineWasSupports = true;
if (!line.arguments) {
break;
}
const argv = line.arguments?.slice(1);
for (const entry of argv) {
if (entry.indexOf('=') !== -1) {
const t = entry.split('=') as string[];
if (t[0] === 'PREFIX') {
const d = t[1].match(/\((\w+)\)(.*)/);
if (d) {
const r = d[1].split('');
const aa = d[2].split('');
r.forEach((value, index) => {
this.serverData.supportedModes[value] = aa[index];
});
}
} else if (t[0] === 'NETWORK') {
this.serverData.network = t[1];
} else if (t[0] === 'CHANNELLEN') {
this.serverData.maxChannelLength = parseInt(t[1], 10);
}
let numeral: string | number = t[1];
if (!isNaN(parseInt(numeral, 10))) {
numeral = parseInt(numeral, 10);
}
this.serverData.serverSupports[t[0]] = numeral;
} else {
this.serverData.serverSupports[entry] = true;
}
}
break;
}
// Set hostname from 396 (non-standard)
case '396':
this.options.hostname = line.arguments?.[1];
break;
// Set hostname from self-whois
case '311':
if (line.arguments?.[1] !== this.options.nick) {
return;
}
this.options.hostname = line.arguments?.[3];
break;
case '321':
this.emit('channel-list-item', {
channel: 'Channel',
users: 'Users',
topic: 'Topic',
});
break;
case '322':
this.emit('channel-list-item', {
channel: line.arguments![1],
users: line.arguments![2],
topic: line.trailing,
});
break;
case 'quit':
if (line.user.nickname !== this.options.nick) {
if (this.nickservStore[line.user.nickname]) {
delete this.nickservStore[line.user.nickname];
}
this.emit('leave', {
nickname: line.user.nickname,
});
}
break;
case 'nick':
if (line.user.nickname === this.options.nick) {
this.options.nick = line.arguments?.[0] || 'unknown';
} else if (this.nickservStore[line.user.nickname]) {
delete this.nickservStore[line.user.nickname];
}
this.emit('nick', {
oldNick: line.user.nickname,
newNick: line.arguments?.[0],
});
break;
case 'join':
if (line.user.nickname === this.options.nick && line.trailing) {
this.channels.push(line.trailing);
}
this.emit('join', {
nickname: line.user.nickname,
channel: line.trailing,
});
break;
case 'mode':
let isChannelMode = false;
let method = '+';
let lts = line.trailing ? line.trailing.split(' ') : [];
if (line.arguments![0].indexOf('#') !== -1) {
isChannelMode = true;
}
let modes: string | string[] = line.arguments![1];
if (!modes && line.trailing !== '') {
modes = line.trailing!;
}
let sender = line.user.nickname;
if (sender === '') {
sender = line.user.hostname;
}
method = modes.substring(0, 1);
modes = modes.substring(1).split('');
let pass = [];
if (isChannelMode) {
for (let i in modes) {
let mode = modes[i];
let modei = parseInt(i);
if (this.serverData.supportedModes[mode]) {
this.emit('channel-mode', {
type: method,
mode: mode,
modeTarget: line.arguments![2]
? line.arguments![2 + modei]
? line.arguments![2 + modei]
: lts[modei - 1]
: lts[modei],
...line,
});
} else {
pass.push(mode);
}
}
} else {
pass = modes;
}
if (pass.length > 0) {
this.emit('user-mode', {
type: method,
mode: pass.join(''),
modeTarget: line.arguments![0],
...line,
});
}
break;
case 'part':
case 'kick':
if (line.user.nickname === this.options.nick && line.arguments) {
const indexAt = this.channels.indexOf(line.arguments[0]);
if (indexAt !== -1) {
this.channels.splice(indexAt, 1);
}
}
this.emit('leave', {
nickname: line.user.nickname,
channel: line.arguments?.[0],
});
break;
case 'error':
this.emit('error', { fatal: true, error: new Error(line.raw) });
if (!this.authenticated) {
this.connection?.destroy();
this.connection = undefined;
}
break;
}
}
public async connect(): Promise<void> {
if (this.connection) {
await this.connection.destroy();
}
this.connection = new this.connector(
this.options.ssl ?? false,
this.options.host,
this.options.port,
);
this.connection.on('close', (data) => {
this.emit('disconnect', {
type: 'sock_closed',
raw: data,
message: 'Connection closed.',
});
this.connection = undefined;
this.authenticated = false;
});
this.connection.on('error', (data) => {
this.emit('error', { fatal: true, error: new Error(data) });
this.connection = undefined;
this.authenticated = false;
});
this.connection.on('data', (line: string) => {
const parsedLine = parse(line);
this.emit('line', parsedLine);
this.handleServerLine(parsedLine);
});
await this.connection.connect();
this.authenticate();
}
public async disconnect(reason?: string): Promise<void> {
if (!this.connected) {
if (this.connection) {
await this.connection.destroy();
}
return;
}
this.write('QUIT :%s', reason || 'Client exiting');
// Wait for exit
return new Promise((resolve) => {
const interval = setInterval(() => {
if (!this.connected) {
clearInterval(interval);
resolve();
}
}, 100);
});
}
public get connected() {
return this.connection?.connected ?? false;
}
public async getPing(): Promise<number> {
return new Promise<number>((resolve) => {
const sendTime = Date.now();
this.useCollector(
new Collector('PONG', (line) => {
resolve(Date.now() - sendTime);
}),
'PING :%s',
this.serverData.name,
);
});
}
public async whois(nick: string): Promise<WhoisResponse> {
return new Promise((resolve) => {
this.useCollector(
new WhoisCollector((data) => resolve(parseWhois(data))),
'WHOIS %s',
nick,
);
});
}
public useCollector(collector: IQueue, line: string, ...args: any[]) {
this.queue.push(collector);
this.write(line, ...args);
}
}

0
src/types/events.ts Normal file
View File

View File

@ -0,0 +1,15 @@
export interface IRCCommunicator {
emit(event: string, ...args: any[]): void;
on(event: string, handler: (...args: any[]) => void): void;
write(format: string, ...args: any[]): void;
}
export interface IRCConnector extends IRCCommunicator {
connected: boolean;
connect(): Promise<void>;
destroy(): Promise<void>;
}
export interface IRCConnectorConstructor {
new (secure: boolean, host: string, port?: number): IRCConnector;
}

View File

@ -0,0 +1,55 @@
export interface IIRCUser {
nickname: string;
username: string;
hostname: string;
}
export interface IIRCLine {
user: IIRCUser;
command: string;
arguments?: string[];
trailing?: string;
raw: string;
}
export interface IUserLine {
command: string;
arguments?: string[];
message: string;
}
export interface IQueue<T = any> {
untracked?: boolean;
await: string;
additional?: string[];
from?: string;
buffer?: T;
do(line: IIRCLine, data?: T): void;
digest?(line: IIRCLine): void;
}
export interface INickServOptions {
enabled: boolean;
command: string;
nickservBot?: string;
responseCommand?: string;
}
export interface IIRCOptions {
nick: string;
host: string;
username?: string;
hostname?: string;
realname?: string;
port?: number;
password?: string | null;
sasl?: boolean;
ssl?: boolean;
channels?: string[];
nickserv?: INickServOptions;
}
export interface INickStore {
checked: number;
result: boolean;
}

99
src/utility/collector.ts Normal file
View File

@ -0,0 +1,99 @@
import { IIRCLine, IQueue } from '../types/irc.interfaces';
export class Collector implements IQueue<IIRCLine> {
constructor(
public await: string,
private resolve: (lines: IIRCLine) => void,
public from?: string,
) {}
do(line: IIRCLine): void {
this.resolve(line);
}
}
export class MultiLineCollector implements IQueue<IIRCLine[]> {
public buffer: IIRCLine[] = [];
constructor(
public await: string,
public additional: string[],
private resolve: (lines: IIRCLine[]) => void,
) {}
do(line: IIRCLine, data: IIRCLine[]): void {
this.resolve([...data, line]);
}
digest(line: IIRCLine): void {
this.buffer.push(line);
}
}
/**
* Get a full WHOIS response from the server
*
* `311` - Start of WHOIS, Nickname, hostmask, realname
*
* `319` - Channels
*
* `378` - Connecting from
*
* `379` - User modes
*
* `312` - Server and server name
*
* `313` - User title
*
* `330` - Login time
*
* `335` - Is a bot
*
* `307` - Registered
*
* `671` - Secure connection
*
* `317` - Sign on time and idle time
*
* `318` - End of WHOIS
*/
export class WhoisCollector extends MultiLineCollector {
constructor(resolve: (lines: IIRCLine[]) => void) {
super(
'318', // End of WHOIS
[
'311', // Start of WHOIS, Nickname, hostmask, realname
'319', // Channels
'378', // Connecting from
'379', // User modes
'312', // Server and server name
'313', // User title
'330', // Login time
'335', // Is a bot
'307', // Registered
'671', // Secure connection
'317', // Sign on time and idle time
],
resolve,
);
}
}
/**
* Get a full WHO response from the server
*
* `352` - WHO line: `<channel> <user> <host> <server> <nick> <H|G>[*][@|+] :<hopcount> <real_name>`
*
* `315` - end of WHO
*/
export class WhoCollector extends MultiLineCollector {
constructor(resolve: (lines: IIRCLine[]) => void) {
super(
'315', // End of WHO
[
'352', // WHO line: <channel> <user> <host> <server> <nick> <H|G>[*][@|+] :<hopcount> <real_name>
],
resolve,
);
}
}

View File

@ -0,0 +1,72 @@
import { IRCConnectionWrapper } from '../irc';
import { INickServOptions, INickStore } from '../types/irc.interfaces';
import { Collector } from './collector';
export class NickServCollector extends Collector {
constructor(
nickservOptions: INickServOptions,
resolve: (authed: boolean) => void,
) {
super(
nickservOptions.responseCommand || 'NOTICE',
(line) => {
if (!line.trailing) {
return resolve(false);
}
const splitline = line.trailing.trim().split(' ');
let result = false;
if (splitline[2] !== '0') {
result = true;
}
resolve(result);
},
nickservOptions.nickservBot || 'NickServ',
);
}
}
export class NickServValidator {
public nickservStore: { [key: string]: INickStore } = {};
constructor(public irc: IRCConnectionWrapper) {}
async getNickStatus(nickname: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (this.nickservStore[nickname] != null) {
if (this.nickservStore[nickname].result === true) {
resolve(true);
return;
} else {
if (this.nickservStore[nickname].checked < Date.now() - 1800000) {
// 30 minutes
delete this.nickservStore[nickname];
}
}
}
if (
this.irc.options.nickserv &&
this.irc.options.nickserv.enabled &&
this.irc.options.nickserv.command
) {
this.irc.useCollector(
new NickServCollector(
this.irc.options.nickserv,
(result: boolean) => {
this.nickservStore[nickname] = {
result,
checked: Date.now(),
};
resolve(result);
},
),
'PRIVMSG nickserv :%s %s',
this.irc.options.nickserv.command,
nickname,
);
}
});
}
}

63
src/utility/parser.ts Normal file
View File

@ -0,0 +1,63 @@
import { IIRCLine } from '../types/irc.interfaces';
function parseERROR(line: string[]): IIRCLine {
const final: IIRCLine = {
user: { nickname: '', username: '', hostname: '' },
command: 'ERROR',
trailing: '',
raw: line.join(' '),
};
let pass1 = line.slice(1).join(' ');
if (pass1.indexOf(':') === 0) {
pass1 = pass1.substring(1);
}
final.trailing = pass1;
return final;
}
export function parse(rawline: string): IIRCLine {
const final: IIRCLine = {
user: {
nickname: '',
username: '',
hostname: '',
},
command: '',
arguments: [],
trailing: '',
raw: rawline,
};
const pass1 =
rawline.indexOf(':') === 0
? rawline.substring(1).split(' ')
: rawline.split(' ');
if (pass1[0] === 'ERROR') {
return parseERROR(pass1);
}
if (pass1[0].indexOf('!') !== -1) {
const nickuser = pass1[0].split('!');
final.user.nickname = nickuser[0];
const userhost = nickuser[1].split('@');
final.user.username = userhost[0];
final.user.hostname = userhost[1];
} else {
final.user.hostname = pass1[0];
}
final.command = pass1[1];
const pass2 = pass1.slice(2).join(' ');
if (pass2.indexOf(':') !== -1) {
final.arguments = pass2.substring(0, pass2.indexOf(' :')).split(' ');
final.trailing = pass2.substring(pass2.indexOf(':') + 1);
} else {
final.arguments = pass2.split(' ');
}
return final;
}

View File

@ -0,0 +1,38 @@
export type EventHandler = (...args: any[]) => void;
export class SimpleEventEmitter {
private _handlers: { [x: string]: EventHandler[] } = {};
on(event: string, fn: EventHandler) {
if (typeof fn !== 'function') {
return;
}
if (!this._handlers[event]) {
this._handlers[event] = [];
}
this._handlers[event].push(fn);
}
emit(event: string, ...args: any[]): void {
if (!this._handlers[event]) {
return;
}
this._handlers[event]
.filter((fn) => fn && typeof fn === 'function')
.forEach((fn) => fn(...args));
}
removeEventListener(event: string, fn: EventHandler): void {
if (!this._handlers[event] || typeof fn !== 'function') {
return;
}
const indexOf = this._handlers[event].indexOf(fn);
if (indexOf > -1) {
this._handlers[event].splice(indexOf, 1);
}
}
}

17
src/utility/truncate.ts Normal file
View File

@ -0,0 +1,17 @@
// Chop message into pieces recursively, splitting them at lenoffset
export function truncate(msg: string, lenoffset: number): string[] {
let pieces: string[] = [];
if (msg.length <= lenoffset) {
pieces.push(msg);
} else {
const m1 = msg.substring(0, lenoffset);
const m2 = msg.substring(lenoffset);
pieces.push(m1);
if (m2.length > lenoffset) {
pieces = pieces.concat(truncate(m2, lenoffset));
} else {
pieces.push(m2);
}
}
return pieces;
}

View File

@ -0,0 +1,63 @@
import { IUserLine } from '../types/irc.interfaces';
import { truncate } from './truncate';
export function mapUserInput(data: IUserLine, msgMaxLength = 512): string[][] {
let output: string[][] = [];
switch (data.command) {
case 'topic':
output.push([
'TOPIC %s',
data.arguments![0],
data.message !== '' ? ' :' + data.message : '',
]);
break;
case 'kick':
output.push(['KICK %s :%s', data.arguments!.join(' '), data.message]);
break;
case 'part':
output.push(['PART %s :%s', data.arguments![0], data.message]);
break;
case 'nick':
case 'whois':
case 'who':
case 'names':
case 'join':
output.push(['JOIN %s', data.arguments![0]]);
break;
case 'quit':
output.push(['QUIT :%s', data.message]);
break;
case 'privmsg':
case 'notice':
const split = data.message.split('\n');
for (const splitMsg of split) {
const messages = truncate(splitMsg, msgMaxLength);
output.push(
...messages.map((msg) => [
'%s %s :%s',
data.command.toUpperCase(),
data.arguments![0],
msg,
]),
);
}
break;
case 'list':
output.push([data.command.toUpperCase()]);
break;
case 'ctcp':
let ctcpmsg = '';
if (data.arguments![1].toLowerCase() === 'ping') {
ctcpmsg = 'PING ' + Math.floor(Date.now() / 1000);
} else {
ctcpmsg = data.message;
}
output.push(['PRIVMSG %s :\x01%s\x01', data.arguments![0], ctcpmsg]);
break;
default:
output.push([data.command.toUpperCase(), data.message]);
}
return output;
}

View File

@ -0,0 +1,67 @@
import { IIRCLine } from '../types/irc.interfaces';
export interface WhoisResponse {
nickname?: string;
hostmask?: string;
realname?: string;
channels?: string[];
connectingFrom?: string;
usingModes?: string;
server?: string;
serverName?: string;
title?: string;
loggedInAs?: string;
bot?: boolean;
registered?: boolean;
secure?: boolean;
signOnTime?: number;
idleSeconds?: number;
}
export function parseWhois(lines: IIRCLine[]) {
const data: WhoisResponse = {};
lines.forEach((line) => {
switch (line.command) {
case '311':
data.nickname = line.arguments![1];
data.hostmask = `${line.arguments![2]}@${line.arguments![3]}`;
data.realname = line.trailing || '';
break;
case '319':
data.channels = line.trailing?.split(' ');
break;
case '378':
data.connectingFrom = line.trailing;
break;
case '379':
data.usingModes = line.trailing;
break;
case '312':
data.server = line.arguments![2];
data.serverName = line.trailing || '';
break;
case '313':
data.title = line.trailing;
break;
case '330':
data.loggedInAs = line.arguments![2];
break;
case '335':
data.bot = true;
break;
case '307':
data.registered = true;
break;
case '671':
data.secure = true;
break;
case '317':
data.signOnTime = parseInt(line.arguments![3], 10);
data.idleSeconds = parseInt(line.arguments![2], 10);
break;
}
});
return data;
}

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "commonjs" /* Specify what module code is generated. */,
"declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
"outDir": "./lib" /* Specify an output folder for all emitted files. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}