add a nicklist manager class

This commit is contained in:
Evert Prants 2022-09-24 14:00:12 +03:00
parent ad0ed84f7e
commit 280c8a5e2f
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
11 changed files with 582 additions and 12 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
/node_modules/
/lib/
/docs

173
package-lock.json generated
View File

@ -11,6 +11,7 @@
"devDependencies": {
"@types/node": "^18.7.18",
"prettier": "^2.7.1",
"typedoc": "^0.23.15",
"typescript": "^4.8.3"
}
},
@ -20,6 +21,57 @@
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==",
"dev": true
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"node_modules/lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"node_modules/marked": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.1.0.tgz",
"integrity": "sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA==",
"dev": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/minimatch": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
"integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
@ -35,6 +87,38 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/shiki": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-0.11.1.tgz",
"integrity": "sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA==",
"dev": true,
"dependencies": {
"jsonc-parser": "^3.0.0",
"vscode-oniguruma": "^1.6.1",
"vscode-textmate": "^6.0.0"
}
},
"node_modules/typedoc": {
"version": "0.23.15",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.23.15.tgz",
"integrity": "sha512-x9Zu+tTnwxb9YdVr+zvX7LYzyBl1nieOr6lrSHbHsA22/RJK2m4Y525WIg5Mj4jWCmfL47v6f4hUzY7EIuwS5w==",
"dev": true,
"dependencies": {
"lunr": "^2.3.9",
"marked": "^4.0.19",
"minimatch": "^5.1.0",
"shiki": "^0.11.1"
},
"bin": {
"typedoc": "bin/typedoc"
},
"engines": {
"node": ">= 14.14"
},
"peerDependencies": {
"typescript": "4.6.x || 4.7.x || 4.8.x"
}
},
"node_modules/typescript": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz",
@ -47,6 +131,18 @@
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/vscode-oniguruma": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz",
"integrity": "sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==",
"dev": true
},
"node_modules/vscode-textmate": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-6.0.0.tgz",
"integrity": "sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ==",
"dev": true
}
},
"dependencies": {
@ -56,17 +152,94 @@
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==",
"dev": true
},
"balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
},
"jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"marked": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.1.0.tgz",
"integrity": "sha512-+Z6KDjSPa6/723PQYyc1axYZpYYpDnECDaU6hkaf5gqBieBkMKYReL5hteF2QizhlMbgbo8umXl/clZ67+GlsA==",
"dev": true
},
"minimatch": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
"integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
},
"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
},
"shiki": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-0.11.1.tgz",
"integrity": "sha512-EugY9VASFuDqOexOgXR18ZV+TbFrQHeCpEYaXamO+SZlsnT/2LxuLBX25GGtIrwaEVFXUAbUQ601SWE2rMwWHA==",
"dev": true,
"requires": {
"jsonc-parser": "^3.0.0",
"vscode-oniguruma": "^1.6.1",
"vscode-textmate": "^6.0.0"
}
},
"typedoc": {
"version": "0.23.15",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.23.15.tgz",
"integrity": "sha512-x9Zu+tTnwxb9YdVr+zvX7LYzyBl1nieOr6lrSHbHsA22/RJK2m4Y525WIg5Mj4jWCmfL47v6f4hUzY7EIuwS5w==",
"dev": true,
"requires": {
"lunr": "^2.3.9",
"marked": "^4.0.19",
"minimatch": "^5.1.0",
"shiki": "^0.11.1"
}
},
"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
},
"vscode-oniguruma": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.6.2.tgz",
"integrity": "sha512-KH8+KKov5eS/9WhofZR8M8dMHWN2gTxjMsG4jd04YhpbPR91fUj7rYQ2/XjeHCJWbg7X++ApRIU9NUwM2vTvLA==",
"dev": true
},
"vscode-textmate": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-6.0.0.tgz",
"integrity": "sha512-gu73tuZfJgu+mvCSy4UZwd2JXykjK9zAZsfmDeut5dx/1a7FeTk0XwJsSuqQn+cuMCGVbIBfl+s53X4T19DnzQ==",
"dev": true
}
}
}

View File

@ -14,6 +14,7 @@
"devDependencies": {
"@types/node": "^18.7.18",
"prettier": "^2.7.1",
"typedoc": "^0.23.15",
"typescript": "^4.8.3"
}
}

View File

@ -4,6 +4,7 @@ import {
applyTextColor,
wrapFormattedText,
} from '../utility/message-formatting';
import { IRCNickList } from '../utility/nicklist';
import { NickServValidator } from '../utility/nickserv-validator';
const bot = new IRCBot({
@ -18,6 +19,7 @@ const bot = new IRCBot({
});
const nickserv = new NickServValidator(bot);
const nicklist = new IRCNickList(bot);
bot.on('authenticated', () => {
console.log('Successful connection!');
@ -91,4 +93,6 @@ bot.on('disconnect', console.log);
bot.on('names', console.log);
nicklist.on('update', () => console.log(JSON.stringify(nicklist.channels)));
bot.connect();

View File

@ -2,6 +2,7 @@ export * from './types/events';
export * from './types/irc.interfaces';
export * from './types/impl.interface';
export * from './utility/nicklist';
export * from './utility/collector';
export * from './utility/estimate-prefix';
export * from './utility/formatstr';

View File

@ -560,7 +560,7 @@ export class IRCConnectionWrapper
public async whois(nick: string): Promise<WhoisResponse> {
return new Promise((resolve) => {
this.useCollector(
new WhoisCollector((data) => resolve(parseWhois(data))),
new WhoisCollector((data) => resolve(parseWhois(data)), nick),
'WHOIS %s',
nick,
);
@ -575,7 +575,7 @@ export class IRCConnectionWrapper
public async who(target: string): Promise<WhoResponse[]> {
return new Promise((resolve) => {
this.useCollector(
new WhoCollector((lines) => resolve(parseWho(lines))),
new WhoCollector((lines) => resolve(parseWho(lines)), target),
'WHO %s',
target,
);

View File

@ -66,10 +66,7 @@ export class MultiLineCollector implements IQueue<IIRCLine[]> {
* `318` - End of WHOIS
*/
export class WhoisCollector extends MultiLineCollector {
constructor(
resolve: (lines: IIRCLine[]) => void,
match?: (line: IIRCLine) => boolean,
) {
constructor(resolve: (lines: IIRCLine[]) => void, target: string) {
super(
'318', // End of WHOIS
[
@ -86,7 +83,7 @@ export class WhoisCollector extends MultiLineCollector {
'317', // Sign on time and idle time
],
resolve,
match,
(line) => target === line.arguments[1],
);
}
}
@ -99,17 +96,14 @@ export class WhoisCollector extends MultiLineCollector {
* `315` - end of WHO
*/
export class WhoCollector extends MultiLineCollector {
constructor(
resolve: (lines: IIRCLine[]) => void,
match?: (line: IIRCLine) => boolean,
) {
constructor(resolve: (lines: IIRCLine[]) => void, target: string) {
super(
'315', // End of WHO
[
'352', // WHO line: <channel> <user> <host> <server> <nick> <H|G>[*][@|+] :<hopcount> <real_name>
],
resolve,
match,
(line) => line.arguments[1] === target || line.arguments[2] === target,
);
}
}

View File

@ -0,0 +1,3 @@
export * from './nicklist.interfaces';
export * from './nicklist.events';
export * from './nicklist';

View File

@ -0,0 +1,4 @@
export type NickListEvents = {
update: (channels?: string[]) => void;
channelRemoved: (channel: string) => void;
};

View File

@ -0,0 +1,11 @@
export interface INicklistNick {
nickname: string;
modes: string[];
prefix?: string;
}
export interface INicklistChannel {
channel: string;
topic?: string;
nicks: INicklistNick[];
}

View File

@ -0,0 +1,378 @@
import { IRCConnectionWrapper } from '../../irc';
import { modeFromPrefix } from '../mode-from-prefix';
import { TypedEventEmitter } from '../typed-event-emitter';
import { WhoResponse } from '../who-parser';
import { NickListEvents } from './nicklist.events';
import { INicklistChannel } from './nicklist.interfaces';
/**
* Manages a nicklist for your IRC connection, complete with modes and prefixes.
*/
export class IRCNickList extends TypedEventEmitter<NickListEvents> {
public channels: INicklistChannel[] = [];
constructor(public irc: IRCConnectionWrapper) {
super();
if (this.irc.authenticated) {
this.handlers();
}
this.irc.on('authenticated', () => this.handlers());
}
/**
* Get my nickname
*/
public get nickname() {
return this.irc.options.nick;
}
/**
* Available user prefixes
*/
public get prefixes() {
return Object.values(this.irc.serverData.supportedModes);
}
/**
* Available user modes with prefixes
*/
public get modes() {
return Object.keys(this.irc.serverData.supportedModes);
}
/**
* List of supported prefix modes
*/
public get supportedModes() {
return this.irc.serverData.supportedModes;
}
/**
* Does the provided nickname currently have a supported prefix.
* @param nick Nickname with a prefix
* @returns boolean
*/
public nickHasPrefix(nick: string): boolean {
return this.prefixes.some((prefix) => nick.startsWith(prefix));
}
/**
* Strip prefix from nickname, if exists
* @param nick Nickname with a prefix
* @returns Nickname without prefix
*/
public stripPrefix(nick: string) {
if (this.nickHasPrefix(nick)) {
return nick.substring(1);
}
return nick;
}
/**
* Find a channel by name
* @param channel Channel name
* @returns Channel object or undefined
*/
public getChannelByName(channel: string) {
return this.channels.find((item) => item.channel === channel);
}
/**
* Handle a user join event.
* @param channel Channel
* @param nickname Nickname
*/
public handleJoin(channel: string, nickname: string): void {
const channelObject = this.getChannelByName(channel);
if (nickname === this.nickname) {
if (!channelObject) {
this.channels.push({
channel,
nicks: [],
});
}
this.irc.who(channel).then((response) => this.handleWho(response));
this.emit('update', [channel]);
return;
}
channelObject?.nicks.push({
nickname,
modes: [],
});
this.emit('update', [channel]);
}
/**
* Handle `NAMES` reply.
* @param channel Channel
* @param names Nick list
*/
public handleNames(channel: string, names: string[]): void {
const channelObject = this.getChannelByName(channel);
if (!channelObject) {
return;
}
for (const nick of names) {
const prefixed = this.nickHasPrefix(nick);
const stripped = this.stripPrefix(nick);
const umode = modeFromPrefix(nick, this.supportedModes);
const existsNick = channelObject.nicks.find(
(entry) => entry.nickname === stripped,
);
if (!existsNick) {
channelObject.nicks.push({
modes: [umode].filter((x) => x),
nickname: stripped,
prefix: prefixed ? nick.substring(0, 1) : undefined,
});
}
}
this.emit('update', [channel]);
}
/**
* Handle a WHO response list, sets hidden modes for nicks
* @param whoList WHO reply
*/
public handleWho(whoList: WhoResponse[]): void {
let channels = [];
for (const who of whoList) {
if (!who.channel || !who.nickname) {
continue;
}
const channelObject = this.getChannelByName(who.channel);
if (!channelObject) {
continue;
}
channels.push(who.channel);
const providedModeList = who.modes || [];
const userModes = this.prefixes.reduce<string[]>((list, current) => {
const mode = providedModeList.includes(current)
? modeFromPrefix(current, this.supportedModes)
: undefined;
return mode ? [...list, mode] : list;
}, []);
const inListNick = channelObject.nicks.find(
(item) => item.nickname === who.nickname,
);
if (inListNick) {
inListNick.modes = userModes;
inListNick.prefix = this.getPrefixFromModes(inListNick.modes);
continue;
}
channelObject.nicks.push({
nickname: who.nickname,
modes: userModes,
prefix: this.getPrefixFromModes(userModes),
});
}
this.emit('update', channels);
}
/**
* Handles `QUIT`, `PART` and `KICK` events (user left channel).
* @param nickname Nickname
* @param channel Optional channel, if missing, removes from all
*/
public handleLeave(nickname: string, channel?: string): void {
if (nickname === this.nickname) {
if (channel) {
this.channels.splice(
this.channels.findIndex((chan) => chan.channel === channel),
1,
);
this.emit('channelRemoved', channel);
this.emit('update', [channel]);
return;
}
const allMap = this.channels.map((chan) => chan.channel);
this.channels = [];
allMap.forEach((x) => this.emit('channelRemoved', x));
this.emit('update', allMap);
return;
}
if (!channel) {
let channels = [];
for (const chan of this.channels) {
if (this.removeNickIfExists(chan, nickname)) {
channels.push(chan.channel);
}
}
this.emit('update', channels);
return;
}
const channelObject = this.getChannelByName(channel);
if (!channelObject) {
return;
}
this.removeNickIfExists(channelObject, nickname);
this.emit('update', [channel]);
}
/**
* Handle channel modes.
* @param channel Channel
* @param mode Mode
* @param modeTarget User
* @param method Add or remove (`+` or `-`)
*/
public handleMode(
channel: string,
mode: string,
modeTarget: string,
method: string,
): void {
if (!this.modes.includes(mode)) {
return;
}
const chan = this.getChannelByName(channel);
if (!chan) {
return;
}
const nick = this.getNickFromChannel(chan, modeTarget);
if (!nick) {
return;
}
if (method === '+') {
if (nick.modes.includes(mode)) {
return;
}
nick.modes.push(mode);
nick.prefix = this.getPrefixFromModes(nick.modes);
} else {
if (!nick.modes.includes(mode)) {
return;
}
nick.modes.splice(nick.modes.indexOf(mode), 1);
nick.prefix = this.getPrefixFromModes(nick.modes);
}
this.emit('update', [channel]);
}
/**
* Handle nickname changes.
* @param oldNick Old nickname
* @param newNick New nickname
*/
public handleNick(oldNick: string, newNick: string) {
let channels = [];
for (const channel of this.channels) {
const nick = this.getNickFromChannel(channel, oldNick);
if (!nick) {
continue;
}
channels.push(channel.channel);
nick.nickname = newNick;
}
this.emit('update', channels);
}
/**
* Get nick object from channel, if exists.
* @param channel Channel object
* @param nickname Nickname
* @returns Nick object
*/
public getNickFromChannel(channel: INicklistChannel, nickname: string) {
return channel.nicks.find((item) => item.nickname === nickname);
}
/**
* Remove a nickname from channel, if exists.
* @param channel Channel object
* @param nickname Nickname
*/
public removeNickIfExists(
channel: INicklistChannel,
nickname: string,
): boolean {
const inListNick = channel.nicks.findIndex(
(item) => item.nickname === nickname,
);
if (inListNick > -1) {
channel.nicks.splice(inListNick, 1);
return true;
}
return false;
}
/**
* Get the applicable prefix for a list of user channel modes.
* @param modes List of modes
* @returns Prefix or empty string
*/
public getPrefixFromModes(modes: string[]): string {
if (!modes?.length) {
return '';
}
const highestModeWeight = this.modes.reduce<string>(
(last, current, _, array) =>
modes.includes(current)
? last
? array.indexOf(current) < array.indexOf(last)
? current
: last
: current
: last,
'',
);
if (!highestModeWeight) {
return '';
}
return this.prefixes[this.modes.indexOf(highestModeWeight)];
}
private handlers() {
this.irc.on('join', ({ nickname, channel }) => {
this.handleJoin(channel, nickname);
});
this.irc.on(
'channel-mode',
({ type, mode, modeTarget, arguments: [channel] }) => {
this.handleMode(channel, mode, modeTarget, type);
},
);
this.irc.on('leave', ({ nickname, channel }) => {
this.handleLeave(nickname, channel);
});
this.irc.on('nick', ({ oldNick, newNick }) => {
this.handleNick(oldNick, newNick);
});
this.irc.on('names', ({ channel, list }) => {
this.handleNames(channel, list);
});
}
}