Base implementation mostly done
This commit is contained in:
commit
69260ab280
|
@ -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'
|
||||
}
|
||||
}
|
|
@ -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] }}
|
|
@ -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 }}
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.vscode
|
||||
dist
|
||||
coverage
|
||||
.nyc_output
|
|
@ -0,0 +1,4 @@
|
|||
module.exports = {
|
||||
file: ['tests/setup.ts'],
|
||||
exit: true
|
||||
}
|
|
@ -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.
|
|
@ -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
|
File diff suppressed because it is too large
Load Diff
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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')
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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'
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export class Name {
|
||||
constructor (
|
||||
public normal: string,
|
||||
public folded: string
|
||||
) {}
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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] })))
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": [
|
||||
"dist",
|
||||
"tests",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue