Base implementation mostly done

This commit is contained in:
Svante Bengtson 2021-07-12 23:43:29 +00:00
commit 69260ab280
22 changed files with 5174 additions and 0 deletions

11
.eslintrc.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
extends: 'standard-with-typescript',
parserOptions: {
project: 'tsconfig.json'
},
rules: {
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/restrict-template-expressions': 'off'
}
}

55
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Publish to NPM and GPR
on:
release:
types: [created]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: |
~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Use Node.js 14
uses: actions/setup-node@v1
with:
node-version: 14
- run: npm ci --prefer-offline
- run: npm t
publish:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- registry: npm
url: https://registry.npmjs.org/
token: NPM_TOKEN
- registry: gpr
url: https://npm.pkg.github.com/
token: GITHUB_TOKEN
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- uses: actions/setup-node@v2
with:
node-version: 14
registry-url: ${{ matrix.url }}
- run: npm ci --prefer-offline
- run: sed -i 's,"ircstates","@swantzter/ircstates",' package*.json
if: matrix.registry == 'gpr'
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets[matrix.token] }}

57
.github/workflows/qa.yml vendored Normal file
View File

@ -0,0 +1,57 @@
name: QA
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Use Node.js 14
uses: actions/setup-node@v1
with:
node-version: 14
- run: npm ci --prefer-offline
- run: npm run lint
- run: npm run typecheck
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- node: 12
icu: node_modules/full-icu
- node: 14
- node: 16
steps:
- uses: actions/checkout@v2
- uses: actions/cache@v2
with:
path: |
~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- run: npm ci --prefer-offline
- run: npm i --no-save full-icu
if: matrix.node == 12
- run: npm run coverage
env:
NODE_ICU_DATA: ${{ matrix.icu }}
- name: Codecov
if: always() && matrix.node == 14
uses: codecov/codecov-action@v1.0.6
with:
token: ${{ secrets.CODECOV_TOKEN }}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
.vscode
dist
coverage
.nyc_output

4
.mocharc.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
file: ['tests/setup.ts'],
exit: true
}

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2021 Svante Bengtson <svante@swantzter.se> (https://swantzter.se)
Copyright (c) 2020 jesopo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

119
README.md Normal file
View File

@ -0,0 +1,119 @@
# ircstates
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![QA](https://github.com/swantzter/ircstates-js/actions/workflows/qa.yml/badge.svg)](https://github.com/swantzter/ircstates-js/actions/workflows/qa.yml)
[![Publish to NPM and GCR](https://github.com/swantzter/ircstates-js/actions/workflows/publish.yml/badge.svg)](https://github.com/swantzter/ircstates-js/actions/workflows/publish.yml)
[![codecov](https://codecov.io/gh/swantzter/ircstates-js/branch/main/graph/badge.svg)](https://codecov.io/gh/swantzter/ircstates-js)
TypeScript port of the python library [ircstates](https://github.com/jesopo/ircstates).
The major and minor version of this library will aim to follow upstream, patch
will be increased independently.
## rationale
I wanted a bare-bones reference implementation of taking byte input, parsing it
into tokens and then managing an IRC client session state from it.
with this library, you can have client session state managed for you and put
additional arbitrary functionality on top of it.
## usage
### installation
`$ npm install ircstates`
### simple
```typescript
import { Server } from 'ircstates'
const server = new Server("liberachat")
const lines = []
const e = new TextEncoder()
lines.push(server.recv(e.encode(':server 001 nick :hello world!\r\n')))
lines.push(server.recv(e.encode(':nick JOIN #chan\r\n')))
for (const line of lines) {
server.parseTokens(line)
}
const chan = server.channels.get('#chan')
```
### socket to state
```typescript
iimport { Socket } from 'net'
import { Server } from '../src'
import { Line, StatefulEncoder } from 'irctokens'
const NICK = 'nickname'
const CHAN = '#chan'
const HOST = '127.0.0.1'
const PORT = 6667
const server = new Server('liberachat')
const e = new StatefulEncoder()
const s = new Socket()
s.connect(PORT, HOST)
function send (line: Line) {
console.log(`> ${line.format()}`)
e.push(line)
const pending = e.pending()
s.write(pending)
e.pop(pending.length)
}
s.once('connect', () => {
send(new Line({ command: 'USER', params: ['username', '0', '*', 'real name'] }))
send(new Line({ command: 'NICK', params: [NICK] }))
})
s.on('data', data => {
const recvLines = server.recv(Uint8Array.from(data))
for (const line of recvLines) {
server.parseTokens(line)
console.log(`< ${line.format()}`)
}
})
server.on('PING', (line: Line) => send(new Line({ command: 'PONG', params: [line.params[0]] })))
```
### get a user's channels
```typescript
console.log(server.users)
// Map(1) { 'nickname' => User }
const user = server.getUser(NICK) as User
console.log(user)
// User { channels: Set(1) { '#chan' }, username: '~username', hostname: '127.0.0.1' }
console.log(user.channels)
// Set(1) { '#chan' }
```
### get a channel's users
```typescript
console.log(server.channels)
// Map(1) { '#chan' => Channel }
const channel = server.getChannel('#chan')
console.log(channel)
// Channel { ... }
console.log(channel?.users)
// Map(1) { 'nickname' => ChannelUser { modes: Set(0) {} } }
```
### get a user's modes in channel
```typescript
const channel = server.getChannel(CHAN)
const channelUser = channel.users.get(NICK)
console.log(channelUser)
// ChannelUser { modes: Set(0) { 'o', 'v' } }
```
## contact
Come say hi at `#irctokens` on irc.libera.chat

3831
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "ircstates",
"version": "0.11.8",
"description": "IRC client session state parsing library",
"main": "dist/main.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.production.json",
"watch": "npm run build -- --watch",
"test": "ts-mocha tests/**/*.test.ts",
"coverage": "nyc -r lcov -r text npm test",
"lint": "eslint src/**/*.ts tests/**/*.ts",
"lint:fix": "npm run lint -- --fix",
"typecheck": "npm run build -- --noEmit",
"prepack": "npm run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/swantzter/ircstates-js.git"
},
"keywords": [
"irc",
"ircv3",
"rfc1459"
],
"author": "Svante Bengtson <svante@swantzter.se> (https://swantzter.se)",
"license": "MIT",
"bugs": {
"url": "https://github.com/swantzter/ircstates-js/issues"
},
"homepage": "https://github.com/swantzter/ircstates-js#readme",
"files": [
"/dist"
],
"devDependencies": {
"@types/mocha": "^8.2.3",
"@types/node": "^14.17.5",
"@typescript-eslint/eslint-plugin": "^4.28.2",
"eslint": "^7.30.0",
"eslint-config-standard-with-typescript": "^20.0.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.3.1",
"mocha": "^9.0.2",
"nyc": "^15.1.0",
"ts-mocha": "^8.0.0",
"typescript": "^4.2.4"
},
"dependencies": {
"irctokens": "^2.0.0"
},
"peerDependencies": {
"irctokens": "^2.0.0"
}
}

26
src/casemap.ts Normal file
View File

@ -0,0 +1,26 @@
import type { Casemapping } from './isupport'
export const ASCII_UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
export const ASCII_LOWER = 'abcdefghijklmnopqrstuvwxyz'
export const RFC1459_UPPER = ASCII_UPPER + '[]^\\'
export const RFC1459_LOWER = ASCII_LOWER + '{}~|'
function replace (val: string, upper: string, lower: string) {
let out = ''
for (const char of val) {
if (upper.includes(char)) out += lower[upper.indexOf(char)]
else out += char
}
return out
}
export function casefold (mapping: Casemapping, val: string) {
switch (mapping) {
case 'rfc1459':
return replace(val, RFC1459_UPPER, RFC1459_LOWER)
case 'ascii':
return replace(val, ASCII_UPPER, ASCII_LOWER)
default:
throw new TypeError('Invalid mapping provided')
}
}

61
src/channel.ts Normal file
View File

@ -0,0 +1,61 @@
import { ChannelUser } from './channel_user'
import { Name } from './name'
export class Channel {
#name: Name
users: Map<string, ChannelUser> = new Map()
topic?: string
topicSetter?: string
topicTime?: Date
created?: Date
listModes: Map<string, Set<string>> = new Map()
_listModesTemp: Map<string, Set<string>> = new Map()
modes: Map<string, string | undefined> = new Map()
constructor (name: Name) {
this.#name = name
}
getName () {
return this.#name
}
get name () {
return this.#name.normal
}
get nameLower () {
return this.#name.folded
}
changeName (normal: string, folded: string) {
this.#name.normal = normal
this.#name.folded = folded
}
addMode (char: string, listMode: boolean, param?: string) {
if (listMode) {
if (param) {
const listModes = this.listModes.get(char) ?? new Set()
if (!listModes.has(param)) listModes.add(param)
this.listModes.set(char, listModes)
}
} else {
this.modes.set(char, param)
}
}
removeMode (char: string, param?: string) {
if (this.listModes.has(char) && param) {
const listModes = this.listModes.get(char)
listModes?.delete(param)
if (listModes) this.listModes.set(char, listModes)
} else if (this.modes.has(char)) {
this.modes.delete(char)
}
}
}

25
src/channel_user.ts Normal file
View File

@ -0,0 +1,25 @@
import { Name } from './name'
export class ChannelUser {
#nickname: Name
#channelName: Name
modes: Set<string> = new Set()
constructor (nickname: Name, channelName: Name) {
this.#nickname = nickname
this.#channelName = channelName
}
get nickname () {
return this.#nickname.normal
}
get nicknameLower () {
return this.#nickname.folded
}
get channel () {
return this.#channelName.normal
}
}

5
src/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { Server, ServerDisconnectedException } from './server'
export { User } from './user'
export { Channel } from './channel'
export { ChannelUser } from './channel_user'
export { casefold } from './casemap'

112
src/isupport/index.ts Normal file
View File

@ -0,0 +1,112 @@
import { ChanModes, Prefix } from './tokens'
export const CASEMAPPINGS = ['rfc1459', 'ascii'] as const
export type Casemapping = typeof CASEMAPPINGS[number]
function parseEscapes (str: string) {
let out = ''
for (let idx = 0; idx < str.length;) {
if (str[idx] === '\\') {
if (str[idx + 1] === 'x' && str.substring(idx + 2).length >= 2) {
out += String.fromCharCode(parseInt(str.substring(idx + 2, idx + 4), 16))
idx += 4
} else {
out += str[idx + 1]
idx += 2
}
} else {
out += str[idx]
idx++
}
}
return out
}
export class ISupport {
raw: Record<string, string | undefined> = {}
network?: string
chanmodes = new ChanModes(['b'], ['k'], ['l'], ['i', 'm', 'n', 'p', 's', 't'])
prefix = new Prefix(['o', 'v'], ['@', '+'])
modes = 3 // -1 if "no limit"
casemapping: Casemapping = 'rfc1459'
chantypes = ['#']
statusmsg: string[] = []
callerid?: string
excepts?: string
invex?: string
monitor?: number // -1 if "no limit"
watch?: number // -1 if "no limit"
whox = false
nicklen = 9 // from RFC1459
fromTokens (tokens: string[]) {
for (const token of tokens) {
let key: string
let value: string | undefined
; [key, value] = token.split(/=(.*)/)
value = value ? parseEscapes(value) : undefined
this.raw[key] = value
switch (key) {
case 'NETWORK':
this.network = value
break
case 'CHANMODES': {
const [a, b, c, d] = (value as string).split(',').map(l => l.split(''))
this.chanmodes = new ChanModes(a, b, c, d)
break
}
case 'PREFIX': {
const [modes, prefixes] = (value as string).substring(1).split(')').map(l => l.split(''))
this.prefix = new Prefix(modes, prefixes)
break
}
case 'STATUSMSG':
this.statusmsg = value?.split('') ?? []
break
case 'MODES':
this.modes = value ? parseInt(value, 10) : -1
break
case 'MONITOR':
this.monitor = value ? parseInt(value, 10) : -1
break
case 'WATCH':
this.watch = value ? parseInt(value, 10) : -1
break
case 'CASEMAPPING':
if (CASEMAPPINGS.includes(value as Casemapping)) this.casemapping = value as Casemapping
break
case 'CHANTYPES':
this.chantypes = (value as string).split('')
break
case 'CALLERID':
this.callerid = value ?? 'g'
break
case 'EXCEPTS':
this.excepts = value ?? 'e'
break
case 'INVEX':
this.invex = value ?? 'I'
break
case 'WHOX':
this.whox = true
break
case 'NICKLEN':
this.nicklen = parseInt(value as string, 10)
}
}
}
}

23
src/isupport/tokens.ts Normal file
View File

@ -0,0 +1,23 @@
export class ChanModes {
constructor (
public aModes: string[],
public bModes: string[],
public cModes: string[],
public dModes: string[]
) {}
}
export class Prefix {
constructor (
public modes: string[],
public prefixes: string[]
) {}
fromMode (mode: string) {
if (this.modes.includes(mode)) return this.prefixes[this.modes.indexOf(mode)]
}
fromPrefix (prefix: string) {
if (this.prefixes.includes(prefix)) return this.modes[this.prefixes.indexOf(prefix)]
}
}

6
src/name.ts Normal file
View File

@ -0,0 +1,6 @@
export class Name {
constructor (
public normal: string,
public folded: string
) {}
}

79
src/numerics.ts Normal file
View File

@ -0,0 +1,79 @@
export enum Numeric {
RPL_WELCOME = '001',
RPL_ISUPPORT = '005',
RPL_MOTD = '372',
RPL_MOTDSTART = '375',
RPL_ENDOFMOTD = '376',
ERR_NOMOTD = '422',
RPL_UMODEIS = '221',
RPL_VISIBLEHOST = '396',
RPL_TRYAGAIN = '263',
ERR_NOSUCHNICK = '401',
ERR_NOSUCHSERVER = '402',
RPL_CHANNELMODEIS = '324',
RPL_CREATIONTIME = '329',
RPL_TOPIC = '332',
RPL_TOPICWHOTIME = '333',
RPL_WHOREPLY = '352',
RPL_WHOSPCRPL = '354',
RPL_ENDOFWHO = '315',
RPL_NAMREPLY = '353',
RPL_ENDOFNAMES = '366',
RPL_WHOWASUSER = '314',
RPL_ENDOFWHOWAS = '369',
RPL_BANLIST = '367',
RPL_ENDOFBANLIST = '368',
RPL_QUIETLIST = '728',
RPL_ENDOFQUIETLIST = '729',
RPL_LOGGEDIN = '900',
RPL_LOGGEDOUT = '901',
RPL_SASLSUCCESS = '903',
ERR_SASLFAIL = '904',
ERR_SASLTOOLONG = '905',
ERR_SASLABORTED = '906',
ERR_SASLALREADY = '907',
RPL_SASLMECHS = '908',
RPL_WHOISUSER = '311',
RPL_WHOISSERVER = '312',
RPL_WHOISOPERATOR = '313',
RPL_WHOISIDLE = '317',
RPL_WHOISCHANNELS = '319',
RPL_WHOISACCOUNT = '330',
RPL_WHOISHOST = '378',
RPL_WHOISMODES = '379',
RPL_WHOISSECURE = '671',
RPL_AWAY = '301',
RPL_ENDOFWHOIS = '318',
ERR_ERRONEUSNICKNAME = '432',
ERR_NICKNAMEINUSE = '433',
ERR_BANNICKCHANGE = '435',
ERR_UNAVAILRESOURCE = '437',
ERR_NICKTOOFAST = '438',
ERR_CANTCHANGENICK = '447',
ERR_NOSUCHCHANNEL = '403',
ERR_TOOMANYCHANNELS = '405',
ERR_USERONCHANNEL = '443',
ERR_LINKCHANNEL = '470',
ERR_BADCHANNAME = '479',
ERR_BADCHANNEL = '926',
ERR_BANNEDFROMCHAN = '474',
ERR_INVITEONLYCHAN = '473',
ERR_BADCHANNELKEY = '475',
ERR_CHANNELISFULL = '471',
ERR_NEEDREGGEDNICK = '477',
ERR_THROTTLE = '480',
RPL_LOGOFF = '601',
RPL_MONOFFLINE = '731'
}

567
src/server.ts Normal file
View File

@ -0,0 +1,567 @@
import { User } from './user'
import { Channel } from './channel'
import { hostmask, Hostmask, Line, StatefulDecoder } from 'irctokens'
import { casefold } from './casemap'
import { ISupport } from './isupport'
import { Name } from './name'
import { ChannelUser } from './channel_user'
import { EventEmitter } from 'events'
import { Numeric } from './numerics'
export type CommandHandler = (line: Line) => void
export class ServerException extends Error {}
export class ServerDisconnectedException extends Error {}
const WHO_TYPE = '735'
export class Server extends EventEmitter {
constructor (public name: string) {
super()
// TODO: attach all these in a better way, decorators?
this.on(Numeric.RPL_WELCOME, this.handleWelcome)
this.on(Numeric.RPL_ISUPPORT, this.handleISupport)
this.on(Numeric.RPL_MOTDSTART, this.handleMotdStart)
this.on(Numeric.RPL_MOTD, this.handleMotd)
this.on('NICK', this.handleNick)
this.on('JOIN', this.handleJoin)
this.on('PART', this.handlePart)
this.on('KICK', this.handleKick)
this.on('QUIT', this.handleQuit)
this.on('ERROR', this.handleError)
this.on(Numeric.RPL_NAMREPLY, this.handleNames)
this.on(Numeric.RPL_CREATIONTIME, this.handleCreationTime)
this.on('TOPIC', this.handleTopic)
this.on(Numeric.RPL_TOPIC, this.handleTopicNum)
this.on(Numeric.RPL_TOPICWHOTIME, this.handleTopicTime)
this.on('MODE', this.handleMode)
this.on(Numeric.RPL_CHANNELMODEIS, this.handleChannelModeIs)
this.on(Numeric.RPL_UMODEIS, this.handleUModeIs)
this.on(Numeric.RPL_BANLIST, this.handleBanlist)
this.on(Numeric.RPL_ENDOFBANLIST, this.handleBanlistEnd)
this.on(Numeric.RPL_QUIETLIST, this.handleQuietlist)
this.on(Numeric.RPL_ENDOFQUIETLIST, this.handleQuietlistEnd)
this.on('PRIVMSG', this.handleMessage)
this.on('NOTICE', this.handleMessage)
this.on('TAGMSG', this.handleMessage)
this.on(Numeric.RPL_VISIBLEHOST, this.handleVisiblehost)
this.on(Numeric.RPL_WHOREPLY, this.handleWho)
}
nickname = ''
nicknameLower = ''
username?: string
hostname?: string
realname?: string
account?: string
server?: string
away?: string
ip?: string
registered = false
modes: Set<string> = new Set()
motd: string[] = []
#decoder = new StatefulDecoder()
users: Map<string, User> = new Map()
channels: Map<string, Channel> = new Map()
isupport = new ISupport()
hasCap = false
#tempCaps: Record<string, string> = {}
availableCaps: Record<string, string> = {}
agreedCaps: string[] = []
recv (data: Uint8Array) {
const lines = this.#decoder.push(data)
if (!lines) throw new ServerDisconnectedException()
return lines
}
parseTokens (line: Line): void {
if (line.command) this.emit(line.command, line)
}
public casefold (s1: string) {
return casefold(this.isupport.casemapping, s1)
}
casefoldEquals (s1: string, s2: string) {
return this.casefold(s1) === this.casefold(s2)
}
isMe (nickname: string) {
return this.casefold(nickname) === this.nicknameLower
}
hasUser (nickname: string) {
return this.users.get(this.casefold(nickname))
}
getUser (nickname: string) {
return this.users.get(this.casefold(nickname))
}
private addUser (nickname: string, nicknameLower: string) {
const user = new User(new Name(nickname, nicknameLower))
this.users.set(nicknameLower, user)
}
isChannel (target: string) {
return this.isupport.chantypes.includes(target[0])
}
hasChannel (name: string) {
return this.channels.has(this.casefold(name))
}
getChannel (name: string): Channel | undefined {
return this.channels.get(this.casefold(name))
}
private userJoin (channel: Channel, user: User) {
const channelUser = new ChannelUser(user.getName(), channel.getName())
user.channels.add(this.casefold(channel.name))
channel.users.set(user.nicknameLower, channelUser)
return channelUser
}
prepareWhox (target: string) {
return new Line({ command: 'WHO', params: [target, `n%afhinrstu,${WHO_TYPE}`] })
}
private selfHostmask (hostmask: Hostmask) {
this.nickname = hostmask.nickname
if (hostmask.username) this.username = hostmask.username
if (hostmask.hostname) this.hostname = hostmask.hostname
}
// first message reliably sent to us after registration is complete
private handleWelcome (line: Line) {
this.nickname = line.params[0]
this.nicknameLower = this.casefold(line.params[0])
this.registered = true
}
// https://defs.ircdocs.horse/defs/isupport.html
private handleISupport (line: Line) {
const params = [...line.params]
params.pop()
params.shift()
this.isupport.fromTokens(params)
}
// start of MOTD
private handleMotdStart (line: Line) {
this.motd = []
this.handleMotd(line)
}
// line of MOTD
private handleMotd (line: Line) {
this.motd.push(line.params[1])
}
private handleNick (line: Line) {
const newNickname = line.params[0]
const newNicknameLower = this.casefold(newNickname)
const nicknameLower = this.casefold(line.hostmask.nickname)
const user = this.getUser(line.hostmask.nickname)
if (user) {
this.users.delete(nicknameLower)
user.changeNickname(newNickname, newNicknameLower)
this.users.set(newNicknameLower, user)
for (const channelLower of user.channels) {
const channel = this.channels.get(channelLower)
if (!channel) continue
const channelUser = channel.users.get(line.hostmask.nickname)
if (!channelUser) continue
channel.users.delete(nicknameLower)
channel.users.set(user.nicknameLower, channelUser)
}
}
if (this.isMe(line.hostmask.nickname)) {
this.nickname = newNickname
this.nicknameLower = newNicknameLower
}
}
private handleJoin (line: Line) {
const extended = line.params.length === 3
const account = extended ? line.params[1].replace(/(^\*+|\*+$)/g, '') : undefined
const realname = extended ? line.params[2] : undefined
const channelLower = this.casefold(line.params[0])
const nicknameLower = this.casefold(line.hostmask.nickname)
if (this.isMe(nicknameLower)) {
if (!this.channels.has(channelLower)) {
const channel = new Channel(new Name(line.params[0], channelLower))
// TODO: put this somewhere better
for (const mode of this.isupport.chanmodes.aModes) {
channel.listModes.set(mode, new Set())
}
this.channels.set(channelLower, channel)
}
this.selfHostmask(line.hostmask)
if (extended) {
this.account = account
this.realname = realname
}
}
const channel = this.channels.get(channelLower) as Channel
if (channel) {
if (!this.users.has(nicknameLower)) this.addUser(line.hostmask.nickname, nicknameLower)
const user = this.users.get(nicknameLower) as User
if (line.hostmask.username) user.username = line.hostmask.username
if (line.hostmask.hostname) user.hostname = line.hostmask.hostname
if (extended) {
user.account = account
user.realname = realname
}
this.userJoin(channel, user)
}
}
private userPart (line: Line, nickname: string, channelName: string): User | undefined {
const channelLower = this.casefold(channelName)
let user: User | undefined
const channel = this.channels.get(channelLower)
if (channel) {
const nicknameLower = this.casefold(nickname)
user = this.getUser(nickname)
if (user) {
user.channels.delete(channel.nameLower)
channel.users.delete(user.nicknameLower)
if (!user.channels.size) this.users.delete(nicknameLower)
}
if (this.isMe(nickname)) {
this.channels.delete(channelLower)
for (const [key] of channel.users) {
const ruser = this.users.get(key) as User
ruser.channels.delete(channel.nameLower)
if (!ruser.channels.size) this.users.delete(ruser.nicknameLower)
}
}
}
return user
}
private handlePart (line: Line) {
this.userPart(line, line.hostmask.nickname, line.params[0])
}
private handleKick (line: Line) {
this.userPart(line, line.params[1], line.params[0])
}
private selfQuit () {
this.users.clear()
this.channels.clear()
}
private handleQuit (line: Line) {
const nicknameLower = this.casefold(line.hostmask.nickname)
if (this.isMe(nicknameLower)) {
this.selfQuit()
} else {
const user = this.users.get(nicknameLower)
if (user) {
this.users.delete(nicknameLower)
for (const channelLower of user.channels) {
const channel = this.channels.get(channelLower) as Channel
channel.users.delete(user.nicknameLower)
}
}
}
}
private handleError (line: Line) {
this.selfQuit()
}
private handleNames (line: Line) {
const channel = this.getChannel(line.params[2])
if (channel) {
const nicknames = line.params[3].split(' ').filter(n => !!n)
for (const nickname of nicknames) {
let modes = ''
for (const char of nickname) {
const mode = this.isupport.prefix.fromPrefix(char)
if (mode) modes += mode
else break
}
const hm = hostmask(nickname.substring(modes.length))
const nicknameLower = this.casefold(nickname)
if (!this.users.has(nicknameLower)) this.addUser(nickname, nicknameLower)
const user = this.users.get(nicknameLower) as User
const channelUser = this.userJoin(channel, user)
if (hm.username) user.username = hm.username
if (hm.hostname) user.hostname = hm.hostname
if (this.isMe(nicknameLower)) this.selfHostmask(hm)
for (const mode of modes) {
if (!channelUser.modes.has(mode)) channelUser.modes.add(mode)
}
}
}
}
private handleCreationTime (line: Line) {
const channel = this.getChannel(line.params[1])
if (channel) {
channel.created = new Date(parseInt(line.params[2], 10) * 1000)
}
}
private handleTopic (line: Line) {
const channel = this.getChannel(line.params[0])
if (channel) {
channel.topic = line.params[1]
channel.topicSetter = line.source
channel.topicTime = new Date()
}
}
// topic text, "TOPIC #channel" response (and on-join)
private handleTopicNum (line: Line) {
const channel = this.getChannel(line.params[1])
if (channel) {
channel.topic = line.params[2]
}
}
// topic setby, "TOPIC #channel" response (and on-join)
private handleTopicTime (line: Line) {
const channel = this.getChannel(line.params[1])
if (channel) {
channel.topicSetter = line.params[2]
channel.topicTime = new Date(parseInt(line.params[3], 10) * 1000)
}
}
private channelModes (channel: Channel, modes: string[], params: string[]) {
const tokens: Array<[string, string | undefined]> = []
for (const mode of modes) {
const add = mode[0] === '+'
const char = mode[1]
let arg: string | undefined
if (this.isupport.prefix.modes.includes(char)) { // a user's status
arg = params.shift() as string
const user = this.getUser(arg)
if (user) {
const channelUser = channel.users.get(user.nicknameLower) as ChannelUser
if (add) channelUser.modes.add(char)
else channelUser.modes.delete(char)
}
} else {
let hasArg = false
let isList = false
if (this.isupport.chanmodes.aModes.includes(char)) {
hasArg = true
isList = true
} else if (add) {
hasArg = this.isupport.chanmodes.bModes.includes(char) ||
this.isupport.chanmodes.cModes.includes(char)
} else { // remove
hasArg = this.isupport.chanmodes.bModes.includes(char)
}
if (hasArg) {
arg = params.shift()
}
if (add) channel.addMode(char, isList, arg)
else channel.removeMode(char, arg)
}
tokens.push([mode, arg])
}
return tokens
}
private handleMode (line: Line) {
const target = line.params[0]
const modesStr = line.params[1]
const params = line.params.slice(2)
let modifier = '+'
const modes: string[] = []
for (const c of modesStr) {
if (['+', '-'].includes(c)) modifier = c
else modes.push(`${modifier}${c}`)
}
const targetLower = this.casefold(target)
if (this.isMe(targetLower)) {
for (const mode of modes) {
const add = mode[0] === '+'
const char = mode[1]
if (add) this.modes.add(char)
else this.modes.delete(char)
}
} else if (this.channels.has(targetLower)) {
const channel = this.channels.get(targetLower) as Channel
this.channelModes(channel, modes, params)
}
}
// channel modes, "MODE #channel" response (sometimes on-join?)
private handleChannelModeIs (line: Line) {
const channel = this.getChannel(line.params[1])
if (channel) {
const modes = line.params[2].replace(/^\++/, '').split('').map(c => `+${c}`)
const params = line.params.slice(3)
this.channelModes(channel, modes, params)
}
}
// our own user modes, "MODE nickname" response (sometimes on-connect?)
private handleUModeIs (line: Line) {
for (const c of line.params[2].replace(/^\++/, '')) {
this.modes.add(c)
}
}
private modeList (channelName: string, mode: string, mask: string) {
const channel = this.getChannel(channelName)
if (channel) {
if (!channel._listModesTemp.has(mode)) channel._listModesTemp.set(mode, new Set())
channel._listModesTemp.get(mode)?.add(mask)
}
}
private modeListEnd (channelName: string, mode: string) {
const channel = this.getChannel(channelName)
if (channel) {
const mlist = channel._listModesTemp.get(mode)
channel._listModesTemp.delete(mode)
if (mlist) {
channel.listModes.set(mode, mlist)
}
}
}
private handleBanlist ({ params }: Line) {
const channel = params[1]
const mask = params[2]
// if (params.length > 3) {
// // parse these out but we're not storing them yet
// const setBy = params[3]
// const setAt = new Date(parseInt(params[4], 10) * 1000)
// }
this.modeList(channel, 'b', mask)
}
private handleBanlistEnd ({ params }: Line) {
const channel = params[1]
this.modeListEnd(channel, 'b')
}
private handleQuietlist ({ params }: Line) {
const channel = params[1]
const mode = params[2]
const mask = params[3]
// const setBy = params[4]
// const setAt = new Date(parseInt(params[5], 10) * 1000)
this.modeList(channel, mode, mask)
}
private handleQuietlistEnd ({ params }: Line) {
const channel = params[1]
const mode = params[2]
this.modeListEnd(channel, mode)
}
private handleMessage (line: Line) {
if (!line.source) return undefined
// const message = line.params[1]
if (this.isMe(line.hostmask.nickname)) this.selfHostmask(line.hostmask)
let user = this.getUser(line.hostmask.nickname)
if (!user) user = new User(new Name(line.hostmask.nickname, this.casefold(line.hostmask.nickname)))
if (line.hostmask.username) user.username = line.hostmask.username
if (line.hostmask.hostname) user.hostname = line.hostmask.hostname
let target = line.params[0]
const statusmsg = []
while (target) {
if (this.isupport.statusmsg.includes(target[0])) {
statusmsg.push(target[0])
target = target.substring(1)
} else {
break
}
}
}
// our own hostname, sometimes username@hostname, when it changes
private handleVisiblehost (line: Line) {
const [uOrH, hostname] = line.params[1].split(/@(.*)/)
if (hostname) {
this.hostname = hostname
this.username = uOrH
} else {
this.hostname = uOrH
}
}
// WHO line, "WHO #channel|nickname" response
private handleWho (line: Line) {
const nickname = line.params[5]
const username = line.params[2]
const hostname = line.params[3]
const status = line.params[6]
const away = status.includes('G') ? '' : undefined
const realname = line.params[7].split(/ (.*)/)[1]
const server = line.params[4] === '*' ? undefined : line.params[4]
if (this.isMe(nickname)) {
this.username = username
this.hostname = hostname
this.realname = realname
this.server = server
this.away = away
}
const user = this.getUser(nickname)
if (user) {
user.username = username
user.hostname = hostname
user.realname = realname
user.server = server
user.away = away
}
}
}

47
src/user.ts Normal file
View File

@ -0,0 +1,47 @@
import { Name } from './name'
export class User {
#nickname: Name
username?: string
hostname?: string
realname?: string
account?: string
server?: string
away?: string
ip?: string
channels: Set<string> = new Set()
constructor (nickname: Name) {
this.#nickname = nickname
}
getName () {
return this.#nickname
}
get nickname () {
return this.#nickname.normal
}
get nicknameLower () {
return this.#nickname.folded
}
changeNickname (normal: string, folded: string) {
this.#nickname.normal = normal
this.#nickname.folded = folded
}
hostmask () {
let hostmask: string = this.nickname
if (this.username) hostmask += `!${this.username}`
if (this.hostname) hostmask += `@${this.hostname}`
return hostmask
}
userhost () {
if (this.username && this.hostname) return `${this.username}@${this.hostname}`
return undefined
}
}

39
tests/setup.ts Normal file
View File

@ -0,0 +1,39 @@
import { Socket } from 'net'
import { Server } from '../src'
import { Line, StatefulEncoder } from 'irctokens'
import { Numeric } from '../src/numerics'
const NICK = 'TestNick123412'
const CHAN = '##sometestchannel'
const HOST = 'irc.libera.chat'
const PORT = 6667
const server = new Server('liberachat')
const e = new StatefulEncoder()
const s = new Socket()
s.connect(PORT, HOST)
function send (line: Line) {
console.log(`> ${line.format()}`)
e.push(line)
const pending = e.pending()
s.write(pending)
e.pop(pending.length)
}
s.once('connect', () => {
send(new Line({ command: 'USER', params: ['username', '0', '*', 'real name'] }))
send(new Line({ command: 'NICK', params: [NICK] }))
})
s.on('data', data => {
const recvLines = server.recv(Uint8Array.from(data))
for (const line of recvLines) {
server.parseTokens(line)
console.log(`< ${line.format()}`)
}
})
server.on('PING', line => send(new Line({ command: 'PING', params: [line.params[0]] })))
server.on(Numeric.RPL_WELCOME, line => send(new Line({ command: 'JOIN', params: [CHAN] })))

17
tsconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2019",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"moduleResolution": "node",
"esModuleInterop": true,
"experimentalDecorators": true
},
"exclude": [
"dist",
"node_modules"
]
}

8
tsconfig.production.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig",
"exclude": [
"dist",
"tests",
"node_modules"
]
}