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