commit ea7f81d9fe685d76ce5cb74e82db0dbc39f0c52f Author: Evert Prants Date: Fri Sep 23 20:38:41 2022 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f028e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/node_modules/ +/lib/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..95c9b06 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +src/ +.prettierrc +.vscode diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..082c106 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..313fbb3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.formatOnSave": true, + "files.insertFinalNewline": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "eslint.validate": [ + "typescript", + ], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c9a301b --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a136aa6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..7523179 --- /dev/null +++ b/src/bot.ts @@ -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); + } +} diff --git a/src/connector/net.connector.ts b/src/connector/net.connector.ts new file mode 100644 index 0000000..5736e81 --- /dev/null +++ b/src/connector/net.connector.ts @@ -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 { + 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 { + 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); + }); + } +} diff --git a/src/connector/websocket.connector.ts b/src/connector/websocket.connector.ts new file mode 100644 index 0000000..3bc885e --- /dev/null +++ b/src/connector/websocket.connector.ts @@ -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 { + 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 { + 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); + }); + } +} diff --git a/src/examples/connection-test.ts b/src/examples/connection-test.ts new file mode 100644 index 0000000..1318796 --- /dev/null +++ b/src/examples/connection-test.ts @@ -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(); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8b54c72 --- /dev/null +++ b/src/index.ts @@ -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'; diff --git a/src/irc.ts b/src/irc.ts new file mode 100644 index 0000000..fc73d2e --- /dev/null +++ b/src/irc.ts @@ -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 { + 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 { + 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 { + return new Promise((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 { + 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); + } +} diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/types/impl.interface.ts b/src/types/impl.interface.ts new file mode 100644 index 0000000..1ed6300 --- /dev/null +++ b/src/types/impl.interface.ts @@ -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; + destroy(): Promise; +} + +export interface IRCConnectorConstructor { + new (secure: boolean, host: string, port?: number): IRCConnector; +} diff --git a/src/types/irc.interfaces.ts b/src/types/irc.interfaces.ts new file mode 100644 index 0000000..1706958 --- /dev/null +++ b/src/types/irc.interfaces.ts @@ -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 { + 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; +} diff --git a/src/utility/collector.ts b/src/utility/collector.ts new file mode 100644 index 0000000..fc62fff --- /dev/null +++ b/src/utility/collector.ts @@ -0,0 +1,99 @@ +import { IIRCLine, IQueue } from '../types/irc.interfaces'; + +export class Collector implements IQueue { + constructor( + public await: string, + private resolve: (lines: IIRCLine) => void, + public from?: string, + ) {} + + do(line: IIRCLine): void { + this.resolve(line); + } +} + +export class MultiLineCollector implements IQueue { + 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: ` [*][@|+] : ` + * + * `315` - end of WHO + */ +export class WhoCollector extends MultiLineCollector { + constructor(resolve: (lines: IIRCLine[]) => void) { + super( + '315', // End of WHO + [ + '352', // WHO line: [*][@|+] : + ], + resolve, + ); + } +} diff --git a/src/utility/nickserv-validator.ts b/src/utility/nickserv-validator.ts new file mode 100644 index 0000000..23e5393 --- /dev/null +++ b/src/utility/nickserv-validator.ts @@ -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 { + return new Promise((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, + ); + } + }); + } +} diff --git a/src/utility/parser.ts b/src/utility/parser.ts new file mode 100644 index 0000000..6a59af3 --- /dev/null +++ b/src/utility/parser.ts @@ -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; +} diff --git a/src/utility/simple-event-emitter.ts b/src/utility/simple-event-emitter.ts new file mode 100644 index 0000000..40c6f44 --- /dev/null +++ b/src/utility/simple-event-emitter.ts @@ -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); + } + } +} diff --git a/src/utility/truncate.ts b/src/utility/truncate.ts new file mode 100644 index 0000000..f9bbf71 --- /dev/null +++ b/src/utility/truncate.ts @@ -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; +} diff --git a/src/utility/user-mapper.ts b/src/utility/user-mapper.ts new file mode 100644 index 0000000..5d26b01 --- /dev/null +++ b/src/utility/user-mapper.ts @@ -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; +} diff --git a/src/utility/whois-parser.ts b/src/utility/whois-parser.ts new file mode 100644 index 0000000..8b0a132 --- /dev/null +++ b/src/utility/whois-parser.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1222050 --- /dev/null +++ b/tsconfig.json @@ -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. */ + } +}