initial commit
This commit is contained in:
commit
6fdc96c8bf
|
@ -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: ${{ secrets.NPM_TOKEN }}
|
||||
- registry: gpr
|
||||
url: https://npm.pkg.github.com/
|
||||
token: ${{ secrets.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,"irctokens","@swantzter/irctokens",' package*.json
|
||||
if: matrix.registry == 'gpr'
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ matrix.token }}
|
|
@ -0,0 +1,48 @@
|
|||
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:
|
||||
matrix:
|
||||
node: [12, 14, 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 14
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm ci --prefer-offline
|
||||
- run: npm run coverage
|
||||
- name: Codecov
|
||||
if: 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,3 @@
|
|||
module.exports = {
|
||||
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,99 @@
|
|||
# irctokens
|
||||
|
||||
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
|
||||
[![QA](https://github.com/swantzter/irctokens-js/actions/workflows/qa.yml/badge.svg)](https://github.com/swantzter/irctokens-js/actions/workflows/qa.yml)
|
||||
[![Publish to NPM and GCR](https://github.com/swantzter/irctokens-js/actions/workflows/publish.yml/badge.svg)](https://github.com/swantzter/irctokens-js/actions/workflows/publish.yml)
|
||||
[![codecov](https://codecov.io/gh/swantzter/irctokens-js/branch/main/graph/badge.svg)](https://codecov.io/gh/swantzter/irctokens-js)
|
||||
|
||||
TypeScrip port of the python library [irctokens](https://github.com/jesopo/irctokens)
|
||||
|
||||
## rationale
|
||||
|
||||
there's far too many IRC client implementations out in the world that do not
|
||||
tokenise data correctly and thus fall victim to things like colons either being
|
||||
where you don't expect them or not being where you expect them.
|
||||
|
||||
## usage
|
||||
|
||||
### installation
|
||||
|
||||
`$ npm install irctokens`
|
||||
|
||||
### tokenisation
|
||||
```typescript
|
||||
import { tokenise } from 'irctokens'
|
||||
const line = tokenise('@id=123 :Swant!~swant@hostname PRIVMSG #chat :hello there!')
|
||||
|
||||
console.log(line.tags)
|
||||
// { id: '123' }
|
||||
|
||||
console.log(line.source)
|
||||
// 'Swant!~swant@hostname'
|
||||
|
||||
console.log(line.hostmask)
|
||||
// Hostmask { nickname: 'Swant', username: '~swant', hostname: 'hostname' }
|
||||
|
||||
console.log(line.command)
|
||||
// 'PRIVMSG'
|
||||
|
||||
console.log(line.params)
|
||||
// ['#chat', 'hello there!']
|
||||
```
|
||||
|
||||
### formatting
|
||||
|
||||
```typescript
|
||||
import { Line } from 'irctokens'
|
||||
const line = new Line({ command: 'USER', params: ['user', '0', '*', 'real name'] })
|
||||
|
||||
console.log(line.format())
|
||||
// 'USER user 0 * :real name'
|
||||
```
|
||||
|
||||
### stateful
|
||||
|
||||
below is an example of a fully socket-wise safe IRC client connection that will
|
||||
connect and join a channel. both protocol sending and receiving are handled by
|
||||
irctokens.
|
||||
|
||||
```typescript
|
||||
import { Socket } from 'net'
|
||||
import { Line, StatefulEncoder, StatefulDecoder } from 'irctokens'
|
||||
|
||||
const NICK = 'nickname'
|
||||
const CHAN = '#channel'
|
||||
|
||||
const d = new StatefulDecoder()
|
||||
const e = new StatefulEncoder()
|
||||
const s = new Socket()
|
||||
s.connect(6667, '127.0.0.1')
|
||||
|
||||
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 lines = d.push(Uint8Array.from(data))
|
||||
|
||||
if (!lines) return
|
||||
|
||||
for (const line of lines) {
|
||||
console.log(`< ${line.format()}`)
|
||||
if (line.command === 'PING') send(new Line({ command: 'PONG', params: [line.params[0]] }))
|
||||
else if (line.command === '001') send(new Line({ command: 'JOIN', params: [CHAN] }))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## contact
|
||||
|
||||
Come say hi at `#irctokens` on irc.libera.chat
|
|
@ -0,0 +1,35 @@
|
|||
const { Socket } = require('net')
|
||||
const irctokens = require('.')
|
||||
|
||||
const NICK = 'nickname'
|
||||
const CHAN = '#channel'
|
||||
|
||||
const d = new irctokens.StatefulDecoder()
|
||||
const e = new irctokens.StatefulEncoder()
|
||||
const s = new Socket()
|
||||
s.connect(6667, '127.0.0.1')
|
||||
|
||||
function send (line) { // :Line
|
||||
console.log(`> ${line.format()}`)
|
||||
e.push(line)
|
||||
const pend = e.pending()
|
||||
s.write(pend)
|
||||
e.pop(pend.length)
|
||||
}
|
||||
|
||||
s.once('connect', () => {
|
||||
send(new irctokens.Line({ command: 'USER', params: ['username', '0', '*', 'real name'] }))
|
||||
send(new irctokens.Line({ command: 'NICK', params: [NICK] }))
|
||||
})
|
||||
|
||||
s.on('data', data => {
|
||||
const lines = d.push(Uint8Array.from(data))
|
||||
|
||||
if (!lines) return
|
||||
|
||||
for (const line of lines) {
|
||||
console.log(`< ${line.format()}`)
|
||||
if (line.command === 'PING') send(new irctokens.Line({ command: 'PONG', params: [line.params[0]] }))
|
||||
else if (line.command === '001') send(new irctokens.Line({ command: 'JOIN', params: [CHAN] }))
|
||||
}
|
||||
})
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "irctokens",
|
||||
"version": "2.0.0",
|
||||
"description": "RFC1459 and IRCv3 protocol tokeniser",
|
||||
"main": "dist/index.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/irctokens-js.git"
|
||||
},
|
||||
"keywords": [
|
||||
"irc",
|
||||
"ircv3",
|
||||
"rfc1459",
|
||||
"tokeniser"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">11.0.0"
|
||||
},
|
||||
"author": "Svante Bengtson <svante@swantzter.se> (https://swantzter.se)",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/swantzter/irctokens-js/issues"
|
||||
},
|
||||
"homepage": "https://github.com/swantzter/irctokens-js#readme",
|
||||
"files": [
|
||||
"/dist"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/mocha": "^8.2.3",
|
||||
"@types/node": "^14.17.5",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"@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",
|
||||
"yaml": "^1.10.2"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export const TAG_UNESCAPED = ['\\', ' ', ';', '\r', '\n']
|
||||
export const TAG_UNESCAPED_REGEX = [/\\\\/g, / /g, /;/g, /\r/g, /\n/g]
|
||||
export const TAG_ESCAPED = ['\\\\', '\\s', '\\:', '\\r', '\\n']
|
|
@ -0,0 +1,53 @@
|
|||
import { TAG_ESCAPED, TAG_UNESCAPED_REGEX } from './const'
|
||||
|
||||
function escapeTag (value: string) {
|
||||
let processed = value
|
||||
for (let idx = 0; idx < TAG_ESCAPED.length; idx++) {
|
||||
processed = processed.replace(TAG_UNESCAPED_REGEX[idx], TAG_ESCAPED[idx])
|
||||
}
|
||||
return processed
|
||||
}
|
||||
|
||||
export interface FormatArgs {
|
||||
tags?: Record<string, string>
|
||||
source?: string
|
||||
command: string
|
||||
params: string[]
|
||||
}
|
||||
|
||||
export function format ({ tags, source, command, params }: FormatArgs) {
|
||||
const outs: string[] = []
|
||||
|
||||
if (tags) {
|
||||
const tagsStr = []
|
||||
for (const [key, val] of Object.entries(tags).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (val) tagsStr.push(`${key}=${escapeTag(val)}`)
|
||||
else tagsStr.push(key)
|
||||
}
|
||||
outs.push(`@${tagsStr.join(';')}`)
|
||||
}
|
||||
|
||||
if (source) {
|
||||
outs.push(`:${source}`)
|
||||
}
|
||||
|
||||
outs.push(command)
|
||||
|
||||
if (params) {
|
||||
const paramCopy = [...params]
|
||||
let last = paramCopy.pop()
|
||||
|
||||
for (const param of paramCopy) {
|
||||
if (param.includes(' ')) throw new TypeError('non last params cannot have spaces')
|
||||
else if (param.startsWith(':')) throw new TypeError('non last params cannot start with colon')
|
||||
}
|
||||
outs.push(...paramCopy)
|
||||
|
||||
if (!last || last.includes(' ') || last.startsWith(':')) {
|
||||
last = `:${last ?? ''}`
|
||||
}
|
||||
outs.push(last)
|
||||
}
|
||||
|
||||
return outs.join(' ')
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
export interface HostmaskArgs {
|
||||
source: string
|
||||
nickname: string
|
||||
username?: string
|
||||
hostname?: string
|
||||
}
|
||||
|
||||
export class Hostmask {
|
||||
#source: string
|
||||
nickname: string
|
||||
username?: string
|
||||
hostname?: string
|
||||
|
||||
constructor ({ source, nickname, username, hostname }: HostmaskArgs) {
|
||||
this.#source = source
|
||||
this.nickname = nickname
|
||||
this.username = username
|
||||
this.hostname = hostname
|
||||
}
|
||||
|
||||
toString () {
|
||||
return this.#source
|
||||
}
|
||||
|
||||
eq (other: any) {
|
||||
if (other instanceof Hostmask) return this.toString() === other.toString()
|
||||
else return false
|
||||
}
|
||||
}
|
||||
|
||||
export function hostmask (source: string) {
|
||||
let username, nickname, hostname
|
||||
; [username, hostname] = source.split(/@(.*)/)
|
||||
; [nickname, username] = username.split(/!(.*)/)
|
||||
return new Hostmask({ source, nickname, username, hostname })
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
export { Line, tokenise } from './line'
|
||||
export { Hostmask, hostmask } from './hostmask'
|
||||
export { StatefulDecoder, StatefulEncoder } from './stateful'
|
||||
|
||||
// From https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings
|
||||
export type TextEncodingLabel =
|
||||
'unicode-1-1-utf-8'| 'utf-8'| 'utf8'|
|
||||
'866'| 'cp866'| 'csibm866'| 'ibm866'|
|
||||
'csisolatin2'| 'iso-8859-2'| 'iso-ir-101'| 'iso8859-2'| 'iso88592'| 'iso_8859-2'| 'iso_8859-2:1987'| 'l2'| 'latin2'|
|
||||
'csisolatin3'| 'iso-8859-3'| 'iso-ir-109'| 'iso8859-3'| 'iso88593'| 'iso_8859-3'| 'iso_8859-3:1988'| 'l3'| 'latin3'|
|
||||
'csisolatin4'| 'iso-8859-4'| 'iso-ir-110'| 'iso8859-4'| 'iso88594'| 'iso_8859-4'| 'iso_8859-4:1988'| 'l4'| 'latin4'|
|
||||
'csisolatincyrillic'| 'cyrillic'| 'iso-8859-5'| 'iso-ir-144'| 'iso88595'| 'iso_8859-5'| 'iso_8859-5:1988'|
|
||||
'arabic'| 'asmo-708'| 'csiso88596e'| 'csiso88596i'| 'csisolatinarabic'| 'ecma-114'| 'iso-8859-6'| 'iso-8859-6-e'| 'iso-8859-6-i'| 'iso-ir-127'| 'iso8859-6'| 'iso88596'| 'iso_8859-6'| 'iso_8859-6:1987'|
|
||||
'csisolatingreek'| 'ecma-118'| 'elot_928'| 'greek'| 'greek8'| 'iso-8859-7'| 'iso-ir-126'| 'iso8859-7'| 'iso88597'| 'iso_8859-7'| 'iso_8859-7:1987'| 'sun_eu_greek'|
|
||||
'csiso88598e'| 'csisolatinhebrew'| 'hebrew'| 'iso-8859-8'| 'iso-8859-8-e'| 'iso-ir-138'| 'iso8859-8'| 'iso88598'| 'iso_8859-8'| 'iso_8859-8:1988'| 'visual'|
|
||||
'csiso88598i'| 'iso-8859-8-i'| 'logical'|
|
||||
'csisolatin6'| 'iso-8859-10'| 'iso-ir-157'| 'iso8859-10'| 'iso885910'| 'l6'| 'latin6'|
|
||||
'iso-8859-13'| 'iso8859-13'| 'iso885913'|
|
||||
'iso-8859-14'| 'iso8859-14'| 'iso885914'|
|
||||
'csisolatin9'| 'iso-8859-15'| 'iso8859-15'| 'iso885915'| 'l9'| 'latin9'|
|
||||
'iso-8859-16'|
|
||||
'cskoi8r'| 'koi'| 'koi8'| 'koi8-r'| 'koi8_r'|
|
||||
'koi8-u'|
|
||||
'csmacintosh'| 'mac'| 'macintosh'| 'x-mac-roman'|
|
||||
'dos-874'| 'iso-8859-11'| 'iso8859-11'| 'iso885911'| 'tis-620'| 'windows-874'|
|
||||
'cp1250'| 'windows-1250'| 'x-cp1250'|
|
||||
'cp1251'| 'windows-1251'| 'x-cp1251'|
|
||||
'ansi_x3.4-1968'| 'ascii'| 'cp1252'| 'cp819'| 'csisolatin1'| 'ibm819'| 'iso-8859-1'| 'iso-ir-100'| 'iso8859-1'| 'iso88591'| 'iso_8859-1'| 'iso_8859-1:1987'| 'l1'| 'latin1'| 'us-ascii'| 'windows-1252'| 'x-cp1252'|
|
||||
'cp1253'| 'windows-1253'| 'x-cp1253'|
|
||||
'cp1254'| 'csisolatin5'| 'iso-8859-9'| 'iso-ir-148'| 'iso8859-9'| 'iso88599'| 'iso_8859-9'| 'iso_8859-9:1989'| 'l5'| 'latin5'| 'windows-1254'| 'x-cp1254'|
|
||||
'cp1255'| 'windows-1255'| 'x-cp1255'|
|
||||
'cp1256'| 'windows-1256'| 'x-cp1256'|
|
||||
'cp1257'| 'windows-1257'| 'x-cp1257'|
|
||||
'cp1258'| 'windows-1258'| 'x-cp1258'|
|
||||
'x-mac-cyrillic'| 'x-mac-ukrainian'|
|
||||
'chinese'| 'csgb2312'| 'csiso58gb231280'| 'gb2312'| 'gb_2312'| 'gb_2312-80'| 'gbk'| 'iso-ir-58'| 'x-gbk'|
|
||||
'gb18030'|
|
||||
'hz-gb-2312'|
|
||||
'big5'| 'big5-hkscs'| 'cn-big5'| 'csbig5'| 'x-x-big5'|
|
||||
'cseucpkdfmtjapanese'| 'euc-jp'| 'x-euc-jp'|
|
||||
'csiso2022jp'| 'iso-2022-jp'|
|
||||
'csshiftjis'| 'ms_kanji'| 'shift-jis'| 'shift_jis'| 'sjis'| 'windows-31j'| 'x-sjis'|
|
||||
'cseuckr'| 'csksc56011987'| 'euc-kr'| 'iso-ir-149'| 'korean'| 'ks_c_5601-1987'| 'ks_c_5601-1989'| 'ksc5601'| 'ksc_5601'| 'windows-949'|
|
||||
'csiso2022kr'| 'iso-2022-kr'|
|
||||
'utf-16be'|
|
||||
'utf-16'| 'utf-16le'|
|
||||
'x-user-defined'|
|
||||
'iso-2022-cn'| 'iso-2022-cn-ext'
|
|
@ -0,0 +1,144 @@
|
|||
import { TAG_ESCAPED, TAG_UNESCAPED } from './const'
|
||||
import { format } from './formatting'
|
||||
import { hostmask, Hostmask } from './hostmask'
|
||||
import type { TextEncodingLabel } from '.'
|
||||
|
||||
export interface LineArgs {
|
||||
tags?: Record<string, string>
|
||||
source?: string
|
||||
command: string
|
||||
params: string[]
|
||||
}
|
||||
|
||||
export class Line {
|
||||
tags?: Record<string, string>
|
||||
source?: string
|
||||
command: string
|
||||
params: string[]
|
||||
|
||||
constructor ({ tags, source, command, params }: LineArgs) {
|
||||
this.tags = tags
|
||||
this.source = source
|
||||
this.command = command
|
||||
this.params = params
|
||||
}
|
||||
|
||||
#hostmask?: Hostmask
|
||||
get hostmask () {
|
||||
if (this.source) {
|
||||
if (!this.#hostmask) this.#hostmask = hostmask(this.source)
|
||||
return this.#hostmask
|
||||
} else {
|
||||
throw new TypeError('cannot parse hostmask from null source')
|
||||
}
|
||||
}
|
||||
|
||||
format () {
|
||||
return format({
|
||||
tags: this.tags,
|
||||
source: this.source,
|
||||
command: this.command,
|
||||
params: this.params
|
||||
})
|
||||
}
|
||||
|
||||
withSource (source: string) {
|
||||
return new Line({
|
||||
tags: this.tags,
|
||||
source,
|
||||
command: this.command,
|
||||
params: this.params
|
||||
})
|
||||
}
|
||||
|
||||
copy () {
|
||||
return new Line(this)
|
||||
}
|
||||
}
|
||||
|
||||
function unescapeTag (value: string) {
|
||||
let unescaped = ''
|
||||
const escaped = value.split('')
|
||||
while (escaped.length) {
|
||||
const current = escaped.shift() as string
|
||||
if (current === '\\') {
|
||||
if (escaped.length) {
|
||||
const next = escaped.shift() as string
|
||||
const duo = `${current}${next}`
|
||||
const index = TAG_ESCAPED.indexOf(duo)
|
||||
|
||||
if (index > -1) unescaped += TAG_UNESCAPED[index]
|
||||
else unescaped += next
|
||||
}
|
||||
} else {
|
||||
unescaped += current
|
||||
}
|
||||
}
|
||||
return unescaped
|
||||
}
|
||||
|
||||
function _tokenise (line: string) {
|
||||
let value = line
|
||||
let tags: Record<string, string> | undefined
|
||||
if (value[0] === '@') {
|
||||
let tagsS
|
||||
; [tagsS, value] = value.substring(1).split(/ (.*)/)
|
||||
tags = {}
|
||||
for (const part of tagsS.split(';')) {
|
||||
const [key, val] = part.split(/=(.*)/)
|
||||
tags[key] = unescapeTag(val)
|
||||
}
|
||||
}
|
||||
|
||||
let trailing
|
||||
; [value, trailing] = value.split(/ :(.*)/)
|
||||
const params = value.split(' ').filter(part => !!part)
|
||||
|
||||
let source: string | undefined
|
||||
if (params[0][0] === ':') {
|
||||
source = (params.shift() as string).substring(1)
|
||||
}
|
||||
|
||||
if (!params.length) throw TypeError('Cannot tokenise command-less line')
|
||||
const command = (params.shift() as string).toLocaleUpperCase()
|
||||
|
||||
if (trailing) params.push(trailing)
|
||||
|
||||
return new Line({
|
||||
tags,
|
||||
source,
|
||||
command,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function tokenise (
|
||||
line: string | Uint8Array,
|
||||
encoding: TextEncodingLabel = 'utf8',
|
||||
fallback: TextEncodingLabel = 'latin1'
|
||||
) {
|
||||
let dline = ''
|
||||
const decoder = new TextDecoder(encoding, { fatal: true })
|
||||
const fallbackDecoder = new TextDecoder(fallback, { fatal: true })
|
||||
if (line instanceof Uint8Array) {
|
||||
if (line[0] === '@'.charCodeAt(0)) {
|
||||
const idx = line.indexOf(' '.charCodeAt(0))
|
||||
const tagsB = line.slice(0, idx + 1)
|
||||
line = line.slice(idx + 1)
|
||||
dline += decoder.decode(tagsB)
|
||||
}
|
||||
try {
|
||||
dline += decoder.decode(line)
|
||||
} catch {
|
||||
dline += fallbackDecoder.decode(line)
|
||||
}
|
||||
} else {
|
||||
dline = line
|
||||
}
|
||||
|
||||
if (dline.includes('\x00')) {
|
||||
[dline] = dline.split('\x00')
|
||||
}
|
||||
|
||||
return _tokenise(dline)
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
import type { TextEncodingLabel } from '.'
|
||||
import { Line, tokenise } from './line'
|
||||
|
||||
export class StatefulDecoder {
|
||||
#encoding: TextEncodingLabel
|
||||
#fallback: TextEncodingLabel
|
||||
#buffer: Uint8Array = new Uint8Array()
|
||||
|
||||
constructor (encoding: TextEncodingLabel = 'utf8', fallback: TextEncodingLabel = 'latin1') {
|
||||
this.#encoding = encoding
|
||||
this.#fallback = fallback
|
||||
this.clear()
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.#buffer = new Uint8Array()
|
||||
}
|
||||
|
||||
pending () {
|
||||
return this.#buffer
|
||||
}
|
||||
|
||||
push (data: Uint8Array): Line[] | undefined {
|
||||
if (!data.length) return
|
||||
|
||||
const newBuffer = new Uint8Array(this.#buffer.length + data.length)
|
||||
newBuffer.set(this.#buffer)
|
||||
newBuffer.set(data, this.#buffer.length)
|
||||
this.#buffer = newBuffer
|
||||
|
||||
const lineBuffers: Uint8Array[] = []
|
||||
while (this.#buffer.includes('\n'.charCodeAt(0))) {
|
||||
const lfIdx = this.#buffer.indexOf('\n'.charCodeAt(0))
|
||||
const crIdx = this.#buffer.indexOf('\r'.charCodeAt(0))
|
||||
lineBuffers.push(this.#buffer.slice(0, crIdx < lfIdx ? crIdx : lfIdx))
|
||||
this.#buffer = this.#buffer.slice(lfIdx + 1)
|
||||
}
|
||||
|
||||
const lines = []
|
||||
for (const line of lineBuffers) {
|
||||
lines.push(tokenise(line, this.#encoding, this.#fallback))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
export class StatefulEncoder {
|
||||
// #encoding: TextEncodingLabel // TODO only supports utf8 at the moment because of TextEncoder
|
||||
#encoder = new TextEncoder()
|
||||
#buffer = new Uint8Array()
|
||||
#bufferedLines: Line[] = []
|
||||
|
||||
constructor () {
|
||||
this.clear()
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.#buffer = new Uint8Array()
|
||||
this.#bufferedLines = []
|
||||
}
|
||||
|
||||
pending () {
|
||||
return this.#buffer
|
||||
}
|
||||
|
||||
push (line: Line) {
|
||||
const lineBuffer = this.#encoder.encode(`${line.format()}\r\n`)
|
||||
const newBuffer = new Uint8Array(this.#buffer.length + lineBuffer.length)
|
||||
newBuffer.set(this.#buffer)
|
||||
newBuffer.set(lineBuffer, this.#buffer.length)
|
||||
|
||||
this.#buffer = newBuffer
|
||||
this.#bufferedLines.push(line)
|
||||
}
|
||||
|
||||
pop (byteCount: number) {
|
||||
const sent = this.#buffer.slice(0, byteCount).reduce((acc, curr) => {
|
||||
return acc + (curr === '\n'.charCodeAt(0) ? 1 : 0)
|
||||
}, 0)
|
||||
this.#buffer = this.#buffer.slice(byteCount)
|
||||
return new Array(sent).fill(null).map(_ => this.#bufferedLines.shift())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,221 @@
|
|||
# IRC parser tests
|
||||
# joining atoms into sendable messages
|
||||
|
||||
# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
|
||||
#
|
||||
# To the extent possible under law, the author(s) have dedicated all copyright
|
||||
# and related and neighboring rights to this software to the public domain
|
||||
# worldwide. This software is distributed without any warranty.
|
||||
#
|
||||
# You should have received a copy of the CC0 Public Domain Dedication along
|
||||
# with this software. If not, see
|
||||
# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||
|
||||
# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
|
||||
# https://github.com/grawity/code/tree/master/lib/tests
|
||||
# some of the tests here originate from Mozilla's test vectors, which is public domain
|
||||
# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
|
||||
# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
|
||||
# https://github.com/SaberUK/ircparser/tree/master/test
|
||||
|
||||
tests:
|
||||
# the desc string holds a description of the test, if it exists
|
||||
|
||||
# the atoms dict has the keys:
|
||||
# * tags: tags dict
|
||||
# tags with no value are an empty string
|
||||
# * source: source string, without single leading colon
|
||||
# * verb: verb string
|
||||
# * params: params split up as a list
|
||||
# if the params key does not exist, assume it is empty
|
||||
# if any other keys do no exist, assume they are null
|
||||
# a key that is null does not exist or is not specified with the
|
||||
# given input string
|
||||
|
||||
# matches is a list of messages that match
|
||||
|
||||
# simple tests
|
||||
- desc: Simple test with verb and params.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf"
|
||||
matches:
|
||||
- "foo bar baz asdf"
|
||||
- "foo bar baz :asdf"
|
||||
|
||||
# with no regular params
|
||||
- desc: Simple test with source and no params.
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "AWAY"
|
||||
matches:
|
||||
- ":src AWAY"
|
||||
|
||||
- desc: Simple test with source and empty trailing param.
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "AWAY"
|
||||
params:
|
||||
- ""
|
||||
matches:
|
||||
- ":src AWAY :"
|
||||
|
||||
# with source
|
||||
- desc: Simple test with source.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf"
|
||||
matches:
|
||||
- ":coolguy foo bar baz asdf"
|
||||
- ":coolguy foo bar baz :asdf"
|
||||
|
||||
# with trailing param
|
||||
- desc: Simple test with trailing param.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf quux"
|
||||
matches:
|
||||
- "foo bar baz :asdf quux"
|
||||
|
||||
- desc: Simple test with empty trailing param.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ""
|
||||
matches:
|
||||
- "foo bar baz :"
|
||||
|
||||
- desc: Simple test with trailing param containing colon.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ":asdf"
|
||||
matches:
|
||||
- "foo bar baz ::asdf"
|
||||
|
||||
# with source and trailing param
|
||||
- desc: Test with source and trailing param.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf quux"
|
||||
matches:
|
||||
- ":coolguy foo bar baz :asdf quux"
|
||||
|
||||
- desc: Test with trailing containing beginning+end whitespace.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- " asdf quux "
|
||||
matches:
|
||||
- ":coolguy foo bar baz : asdf quux "
|
||||
|
||||
- desc: Test with trailing containing what looks like another trailing param.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "PRIVMSG"
|
||||
params:
|
||||
- "bar"
|
||||
- "lol :) "
|
||||
matches:
|
||||
- ":coolguy PRIVMSG bar :lol :) "
|
||||
|
||||
- desc: Simple test with source and empty trailing.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ""
|
||||
matches:
|
||||
- ":coolguy foo bar baz :"
|
||||
|
||||
- desc: Trailing contains only spaces.
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- " "
|
||||
matches:
|
||||
- ":coolguy foo bar baz : "
|
||||
|
||||
- desc: Param containing tab (tab is not considered SPACE for message splitting).
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "b\tar"
|
||||
- "baz"
|
||||
matches:
|
||||
- ":coolguy foo b\tar baz"
|
||||
- ":coolguy foo b\tar :baz"
|
||||
|
||||
# with tags
|
||||
- desc: Tag with no value and space-filled trailing.
|
||||
atoms:
|
||||
tags:
|
||||
"asd": ""
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- " "
|
||||
matches:
|
||||
- "@asd :coolguy foo bar baz : "
|
||||
|
||||
- desc: Tags with escaped values.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
tags:
|
||||
"a": "b\\and\nk"
|
||||
"d": "gh;764"
|
||||
matches:
|
||||
- "@a=b\\\\and\\nk;d=gh\\:764 foo"
|
||||
- "@d=gh\\:764;a=b\\\\and\\nk foo"
|
||||
|
||||
- desc: Tags with escaped values and params.
|
||||
atoms:
|
||||
verb: "foo"
|
||||
tags:
|
||||
"a": "b\\and\nk"
|
||||
"d": "gh;764"
|
||||
params:
|
||||
- "par1"
|
||||
- "par2"
|
||||
matches:
|
||||
- "@a=b\\\\and\\nk;d=gh\\:764 foo par1 par2"
|
||||
- "@a=b\\\\and\\nk;d=gh\\:764 foo par1 :par2"
|
||||
- "@d=gh\\:764;a=b\\\\and\\nk foo par1 par2"
|
||||
- "@d=gh\\:764;a=b\\\\and\\nk foo par1 :par2"
|
||||
|
||||
- desc: Tag with long, strange values (including LF and newline).
|
||||
atoms:
|
||||
tags:
|
||||
foo: "\\\\;\\s \r\n"
|
||||
verb: "COMMAND"
|
||||
matches:
|
||||
- "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
|
|
@ -0,0 +1,343 @@
|
|||
# IRC parser tests
|
||||
# splitting messages into usable atoms
|
||||
|
||||
# Written in 2015 by Daniel Oaks <daniel@danieloaks.net>
|
||||
#
|
||||
# To the extent possible under law, the author(s) have dedicated all copyright
|
||||
# and related and neighboring rights to this software to the public domain
|
||||
# worldwide. This software is distributed without any warranty.
|
||||
#
|
||||
# You should have received a copy of the CC0 Public Domain Dedication along
|
||||
# with this software. If not, see
|
||||
# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||
|
||||
# some of the tests here originate from grawity's test vectors, which is WTFPL v2 licensed
|
||||
# https://github.com/grawity/code/tree/master/lib/tests
|
||||
# some of the tests here originate from Mozilla's test vectors, which is public domain
|
||||
# https://dxr.mozilla.org/comm-central/source/chat/protocols/irc/test/test_ircMessage.js
|
||||
# some of the tests here originate from SaberUK's test vectors, which he's indicated I am free to include here
|
||||
# https://github.com/SaberUK/ircparser/tree/master/test
|
||||
|
||||
# we follow RFC1459 with regards to multiple ascii spaces splitting atoms:
|
||||
# The prefix, command, and all parameters are
|
||||
# separated by one (or more) ASCII space character(s) (0x20).
|
||||
# because doing it as RFC2812 says (strictly as a single ascii space) isn't sane
|
||||
|
||||
tests:
|
||||
# input is the string coming directly from the server to parse
|
||||
|
||||
# the atoms dict has the keys:
|
||||
# * tags: tags dict
|
||||
# tags with no value are an empty string
|
||||
# * source: source string, without single leading colon
|
||||
# * verb: verb string
|
||||
# * params: params split up as a list
|
||||
# if the params key does not exist, assume it is empty
|
||||
# if any other keys do no exist, assume they are null
|
||||
# a key that is null does not exist or is not specified with the
|
||||
# given input string
|
||||
|
||||
# simple
|
||||
- input: "foo bar baz asdf"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf"
|
||||
|
||||
# with source
|
||||
- input: ":coolguy foo bar baz asdf"
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf"
|
||||
|
||||
# with trailing param
|
||||
- input: "foo bar baz :asdf quux"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf quux"
|
||||
|
||||
- input: "foo bar baz :"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ""
|
||||
|
||||
- input: "foo bar baz ::asdf"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ":asdf"
|
||||
|
||||
# with source and trailing param
|
||||
- input: ":coolguy foo bar baz :asdf quux"
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- "asdf quux"
|
||||
|
||||
- input: ":coolguy foo bar baz : asdf quux "
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- " asdf quux "
|
||||
|
||||
- input: ":coolguy PRIVMSG bar :lol :) "
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "PRIVMSG"
|
||||
params:
|
||||
- "bar"
|
||||
- "lol :) "
|
||||
|
||||
- input: ":coolguy foo bar baz :"
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- ""
|
||||
|
||||
- input: ":coolguy foo bar baz : "
|
||||
atoms:
|
||||
source: "coolguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
- " "
|
||||
|
||||
# with tags
|
||||
- input: "@a=b;c=32;k;rt=ql7 foo"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
tags:
|
||||
"a": "b"
|
||||
"c": "32"
|
||||
"k": ""
|
||||
"rt": "ql7"
|
||||
|
||||
# with escaped tags
|
||||
- input: "@a=b\\\\and\\nk;c=72\\s45;d=gh\\:764 foo"
|
||||
atoms:
|
||||
verb: "foo"
|
||||
tags:
|
||||
"a": "b\\and\nk"
|
||||
"c": "72 45"
|
||||
"d": "gh;764"
|
||||
|
||||
# with tags and source
|
||||
- input: "@c;h=;a=b :quux ab cd"
|
||||
atoms:
|
||||
tags:
|
||||
"c": ""
|
||||
"h": ""
|
||||
"a": "b"
|
||||
source: "quux"
|
||||
verb: "ab"
|
||||
params:
|
||||
- "cd"
|
||||
|
||||
# different forms of last param
|
||||
- input: ":src JOIN #chan"
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "JOIN"
|
||||
params:
|
||||
- "#chan"
|
||||
|
||||
- input: ":src JOIN :#chan"
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "JOIN"
|
||||
params:
|
||||
- "#chan"
|
||||
|
||||
# with and without last param
|
||||
- input: ":src AWAY"
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "AWAY"
|
||||
|
||||
- input: ":src AWAY "
|
||||
atoms:
|
||||
source: "src"
|
||||
verb: "AWAY"
|
||||
|
||||
# tab is not considered <SPACE>
|
||||
- input: ":cool\tguy foo bar baz"
|
||||
atoms:
|
||||
source: "cool\tguy"
|
||||
verb: "foo"
|
||||
params:
|
||||
- "bar"
|
||||
- "baz"
|
||||
|
||||
# with weird control codes in the source
|
||||
- input: ":coolguy!ag@net\x035w\x03ork.admin PRIVMSG foo :bar baz"
|
||||
atoms:
|
||||
source: "coolguy!ag@net\x035w\x03ork.admin"
|
||||
verb: "PRIVMSG"
|
||||
params:
|
||||
- "foo"
|
||||
- "bar baz"
|
||||
|
||||
- input: ":coolguy!~ag@n\x02et\x0305w\x0fork.admin PRIVMSG foo :bar baz"
|
||||
atoms:
|
||||
source: "coolguy!~ag@n\x02et\x0305w\x0fork.admin"
|
||||
verb: "PRIVMSG"
|
||||
params:
|
||||
- "foo"
|
||||
- "bar baz"
|
||||
|
||||
- input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4= :irc.example.com COMMAND param1 param2 :param3 param3"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "value1"
|
||||
tag2: ""
|
||||
vendor1/tag3: "value2"
|
||||
vendor2/tag4: ""
|
||||
source: "irc.example.com"
|
||||
verb: "COMMAND"
|
||||
params:
|
||||
- "param1"
|
||||
- "param2"
|
||||
- "param3 param3"
|
||||
|
||||
- input: ":irc.example.com COMMAND param1 param2 :param3 param3"
|
||||
atoms:
|
||||
source: "irc.example.com"
|
||||
verb: "COMMAND"
|
||||
params:
|
||||
- "param1"
|
||||
- "param2"
|
||||
- "param3 param3"
|
||||
|
||||
- input: "@tag1=value1;tag2;vendor1/tag3=value2;vendor2/tag4 COMMAND param1 param2 :param3 param3"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "value1"
|
||||
tag2: ""
|
||||
vendor1/tag3: "value2"
|
||||
vendor2/tag4: ""
|
||||
verb: "COMMAND"
|
||||
params:
|
||||
- "param1"
|
||||
- "param2"
|
||||
- "param3 param3"
|
||||
|
||||
- input: "COMMAND"
|
||||
atoms:
|
||||
verb: "COMMAND"
|
||||
|
||||
# yaml encoding + slashes is fun
|
||||
- input: "@foo=\\\\\\\\\\:\\\\s\\s\\r\\n COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
foo: "\\\\;\\s \r\n"
|
||||
verb: "COMMAND"
|
||||
|
||||
# broken messages from unreal
|
||||
- input: ":gravel.mozilla.org 432 #momo :Erroneous Nickname: Illegal characters"
|
||||
atoms:
|
||||
source: "gravel.mozilla.org"
|
||||
verb: "432"
|
||||
params:
|
||||
- "#momo"
|
||||
- "Erroneous Nickname: Illegal characters"
|
||||
|
||||
- input: ":gravel.mozilla.org MODE #tckk +n "
|
||||
atoms:
|
||||
source: "gravel.mozilla.org"
|
||||
verb: "MODE"
|
||||
params:
|
||||
- "#tckk"
|
||||
- "+n"
|
||||
|
||||
- input: ":services.esper.net MODE #foo-bar +o foobar "
|
||||
atoms:
|
||||
source: "services.esper.net"
|
||||
verb: "MODE"
|
||||
params:
|
||||
- "#foo-bar"
|
||||
- "+o"
|
||||
- "foobar"
|
||||
|
||||
# tag values should be parsed char-at-a-time to prevent wayward replacements.
|
||||
- input: "@tag1=value\\\\ntest COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "value\\ntest"
|
||||
verb: "COMMAND"
|
||||
|
||||
# If a tag value has a slash followed by a character which doesn't need
|
||||
# to be escaped, the slash should be dropped.
|
||||
- input: "@tag1=value\\1 COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "value1"
|
||||
verb: "COMMAND"
|
||||
|
||||
# A slash at the end of a tag value should be dropped
|
||||
- input: "@tag1=value1\\ COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "value1"
|
||||
verb: "COMMAND"
|
||||
|
||||
# Duplicate tags: Parsers SHOULD disregard all but the final occurence
|
||||
- input: "@tag1=1;tag2=3;tag3=4;tag1=5 COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "5"
|
||||
tag2: "3"
|
||||
tag3: "4"
|
||||
verb: "COMMAND"
|
||||
|
||||
# vendored tags can have the same name as a non-vendored tag
|
||||
- input: "@tag1=1;tag2=3;tag3=4;tag1=5;vendor/tag2=8 COMMAND"
|
||||
atoms:
|
||||
tags:
|
||||
tag1: "5"
|
||||
tag2: "3"
|
||||
tag3: "4"
|
||||
vendor/tag2: "8"
|
||||
verb: "COMMAND"
|
||||
|
||||
# Some parsers handle /MODE in a special way, make sure they do it right
|
||||
- input: ":SomeOp MODE #channel :+i"
|
||||
atoms:
|
||||
source: "SomeOp"
|
||||
verb: "MODE"
|
||||
params:
|
||||
- "#channel"
|
||||
- "+i"
|
||||
|
||||
- input: ":SomeOp MODE #channel +oo SomeUser :AnotherUser"
|
||||
atoms:
|
||||
source: "SomeOp"
|
||||
verb: "MODE"
|
||||
params:
|
||||
- "#channel"
|
||||
- "+oo"
|
||||
- "SomeUser"
|
||||
- "AnotherUser"
|
|
@ -0,0 +1,99 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import { Line } from '../src'
|
||||
|
||||
describe('format', () => {
|
||||
describe('tags', () => {
|
||||
it('Should format a line with tags', () => {
|
||||
const line = new Line({
|
||||
command: 'PRIVMSG',
|
||||
params: ['#channel', 'hello'],
|
||||
tags: { id: '\\ ;\r\n' }
|
||||
})
|
||||
assert.strictEqual(line.format(), '@id=\\\\\\s\\:\\r\\n PRIVMSG #channel hello')
|
||||
})
|
||||
|
||||
it('Should format without tags', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'hello'] })
|
||||
assert.strictEqual(line.format(), 'PRIVMSG #channel hello')
|
||||
})
|
||||
|
||||
it('Should handle tags with undefined value', () => {
|
||||
const line = new Line({
|
||||
command: 'PRIVMSG',
|
||||
params: ['#channel', 'hello'],
|
||||
tags: { a: undefined as unknown as string }
|
||||
})
|
||||
assert.strictEqual(line.format(), '@a PRIVMSG #channel hello')
|
||||
})
|
||||
|
||||
it('Should handle tags with empty string value', () => {
|
||||
const line = new Line({
|
||||
command: 'PRIVMSG',
|
||||
params: ['#channel', 'hello'],
|
||||
tags: { a: '' }
|
||||
})
|
||||
assert.strictEqual(line.format(), '@a PRIVMSG #channel hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('source', () => {
|
||||
it('Should format a line with source', () => {
|
||||
const line = new Line({
|
||||
command: 'PRIVMSG',
|
||||
params: ['#channel', 'hello'],
|
||||
source: 'nick!user@host'
|
||||
})
|
||||
assert.strictEqual(line.format(), ':nick!user@host PRIVMSG #channel hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('command', () => {
|
||||
it('Should format a line with a lowercase command', () => {
|
||||
const line = new Line({ command: 'privmsg', params: null as unknown as string[] })
|
||||
assert.strictEqual(line.format(), 'privmsg')
|
||||
})
|
||||
|
||||
it('Should format a line with an uppercase command', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: null as unknown as string[] })
|
||||
assert.strictEqual(line.format(), 'PRIVMSG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('trailing param', () => {
|
||||
it('Should format a line with a space in the trailing param', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'hello world'] })
|
||||
assert.strictEqual(line.format(), 'PRIVMSG #channel :hello world')
|
||||
})
|
||||
|
||||
it('Should format a line without a space in the trailing param', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: ['#channel', 'helloworld'] })
|
||||
assert.strictEqual(line.format(), 'PRIVMSG #channel helloworld')
|
||||
})
|
||||
|
||||
it('Should format a line with a colon in the trailing param', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: ['#channel', ':helloworld'] })
|
||||
assert.strictEqual(line.format(), 'PRIVMSG #channel ::helloworld')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalid param', () => {
|
||||
it('Should throw if non-last param includes a space', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: ['user', '0 *', 'real name'] })
|
||||
assert.throws(() => {
|
||||
line.format()
|
||||
}, {
|
||||
name: 'TypeError'
|
||||
})
|
||||
})
|
||||
|
||||
it('Should throw if non-lst param includes a colon', () => {
|
||||
const line = new Line({ command: 'PRIVMSG', params: [':#channel', 'hello'] })
|
||||
assert.throws(() => {
|
||||
line.format()
|
||||
}, {
|
||||
name: 'TypeError'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,48 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import { hostmask, tokenise } from '../src'
|
||||
|
||||
describe('Hostmask', () => {
|
||||
it('Should parse n!u@h', () => {
|
||||
const hm = hostmask('nick!user@host')
|
||||
assert.strictEqual(hm.nickname, 'nick')
|
||||
assert.strictEqual(hm.username, 'user')
|
||||
assert.strictEqual(hm.hostname, 'host')
|
||||
})
|
||||
|
||||
it('Should parse n!u', () => {
|
||||
const hm = hostmask('nick!user')
|
||||
assert.strictEqual(hm.nickname, 'nick')
|
||||
assert.strictEqual(hm.username, 'user')
|
||||
assert.strictEqual(hm.hostname, undefined)
|
||||
})
|
||||
|
||||
it('Should parse n@h', () => {
|
||||
const hm = hostmask('nick@host')
|
||||
assert.strictEqual(hm.nickname, 'nick')
|
||||
assert.strictEqual(hm.username, undefined)
|
||||
assert.strictEqual(hm.hostname, 'host')
|
||||
})
|
||||
|
||||
it('Should parse n', () => {
|
||||
const hm = hostmask('nick')
|
||||
assert.strictEqual(hm.nickname, 'nick')
|
||||
assert.strictEqual(hm.username, undefined)
|
||||
assert.strictEqual(hm.hostname, undefined)
|
||||
})
|
||||
|
||||
it('Should be parsed as part of a line', () => {
|
||||
const line = tokenise(':nick!user@host PRIVMSG #channel hello')
|
||||
const hm = hostmask('nick!user@host')
|
||||
assert.deepStrictEqual(line.hostmask, hm)
|
||||
})
|
||||
|
||||
it('Getting line.hostname should throw if message lacks source', () => {
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
assert.throws(() => {
|
||||
line.hostmask
|
||||
}, {
|
||||
name: 'TypeError'
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,45 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import YAML from 'yaml'
|
||||
import path from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { Line, tokenise } from '../src'
|
||||
|
||||
// run test cases sourced from:
|
||||
// https://github.com/ircdocs/parser-tests
|
||||
|
||||
const dataDir = path.join(__dirname, 'data')
|
||||
|
||||
describe('parser tests', () => {
|
||||
describe('split', () => {
|
||||
const { tests } = YAML.parse(readFileSync(path.join(dataDir, 'msg-split.yaml'), 'utf-8'))
|
||||
|
||||
for (const test of tests) {
|
||||
it(`parses ${test.input}`, () => {
|
||||
const tokens = tokenise(test.input)
|
||||
|
||||
assert.deepStrictEqual(tokens.tags, test.atoms.tags)
|
||||
assert.deepStrictEqual(tokens.source, test.atoms.source)
|
||||
assert.deepStrictEqual(tokens.command, test.atoms.verb.toLocaleUpperCase())
|
||||
assert.deepStrictEqual(tokens.params, test.atoms.params ?? [])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('join', () => {
|
||||
const { tests } = YAML.parse(readFileSync(path.join(dataDir, 'msg-join.yaml'), 'utf-8'))
|
||||
|
||||
for (const { atoms, matches } of tests) {
|
||||
it(`joins ${matches}`, () => {
|
||||
const line = new Line({
|
||||
command: atoms.verb,
|
||||
params: atoms.params ?? [],
|
||||
source: atoms.source,
|
||||
tags: atoms.tags
|
||||
})
|
||||
|
||||
assert.ok(matches.includes(line.format()), `\n ${line.format()}\nwas not found in\n ${matches.join('\n ')}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
|
@ -0,0 +1,95 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import { StatefulDecoder, tokenise } from '../src'
|
||||
|
||||
export function str2buf (str: string) {
|
||||
const encoder = new TextEncoder()
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
describe('StatefulDecoder', () => {
|
||||
it('Should decode partial', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
let lines = decoder.push(str2buf('PRIVMSG '))
|
||||
|
||||
assert.deepStrictEqual(lines, [])
|
||||
|
||||
lines = decoder.push(str2buf('#channel hello\r\n'))
|
||||
assert.strictEqual(lines?.length, 1)
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
assert.deepStrictEqual(lines, [line])
|
||||
})
|
||||
|
||||
it('Should decode multiple', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
const lines = decoder.push(str2buf('PRIVMSG #channel1 hello\r\nPRIVMSG #channel2 hello\r\n'))
|
||||
assert.strictEqual(lines?.length, 2)
|
||||
|
||||
const line1 = tokenise('PRIVMSG #channel1 hello')
|
||||
const line2 = tokenise('PRIVMSG #channel2 hello')
|
||||
assert.deepStrictEqual(lines[0], line1)
|
||||
assert.deepStrictEqual(lines[1], line2)
|
||||
})
|
||||
|
||||
it('Should handle non-utf8 encoding', () => {
|
||||
const decoder = new StatefulDecoder('iso-8859-2')
|
||||
const lines = decoder.push(Uint8Array.from([
|
||||
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||
0x6e, 0x6e, 0x65, 0x6c, 0x20, 0x3a, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x20,
|
||||
0xc8,
|
||||
0x0d, 0x0a // \r\n
|
||||
]))
|
||||
const line = tokenise('PRIVMSG #channel :hello Č')
|
||||
|
||||
assert.strictEqual(lines?.length, 1)
|
||||
assert.deepStrictEqual(lines[0], line)
|
||||
})
|
||||
|
||||
it('Should fallback to non-utf encoding', () => {
|
||||
const decoder = new StatefulDecoder('utf8', 'latin1')
|
||||
const lines = decoder.push(Uint8Array.from([
|
||||
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||
0x6e, 0x6e, 0x65, 0x6c, 0x20, 0x68, 0xe9, 0x6c, 0x6c, 0xf3,
|
||||
0x0d, 0x0a // \r\n
|
||||
]))
|
||||
const line = tokenise('PRIVMSG #channel hélló')
|
||||
|
||||
assert.strictEqual(lines?.length, 1)
|
||||
assert.deepStrictEqual(lines[0], line)
|
||||
})
|
||||
|
||||
it('Should return undefined if given no input', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
const lines = decoder.push(new Uint8Array())
|
||||
assert.strictEqual(lines, undefined)
|
||||
})
|
||||
|
||||
it('Should return undefined if given unterminated input', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
decoder.push(str2buf('PRIVMSG #channel hello'))
|
||||
const lines = decoder.push(new Uint8Array())
|
||||
assert.strictEqual(lines, undefined)
|
||||
})
|
||||
|
||||
it('Should be clearable', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
decoder.push(str2buf('PRIVMSG '))
|
||||
decoder.clear()
|
||||
assert.deepStrictEqual(decoder.pending(), new Uint8Array())
|
||||
})
|
||||
|
||||
it('Should handle encoding mismatches', () => {
|
||||
const decoder = new StatefulDecoder()
|
||||
decoder.push(str2buf('@asd=á '))
|
||||
const lines = decoder.push(Uint8Array.from([
|
||||
// latin1: PRIVMSG #chan :á
|
||||
0x50, 0x52, 0x49, 0x56, 0x4d, 0x53, 0x47, 0x20, 0x23, 0x63, 0x68, 0x61,
|
||||
0x6e, 0x20, 0x3a, 0xe1,
|
||||
0x0d, 0x0a // \r\n
|
||||
]))
|
||||
|
||||
assert.strictEqual(lines?.length, 1)
|
||||
assert.strictEqual(lines[0].params[1], 'á')
|
||||
assert.strictEqual(lines[0].tags?.asd, 'á')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,49 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import { StatefulEncoder, tokenise } from '../src'
|
||||
import { str2buf } from './stateful-decode.test'
|
||||
|
||||
describe('StatefulEncoder', () => {
|
||||
it('Should push a line', () => {
|
||||
const encoder = new StatefulEncoder()
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
encoder.push(line)
|
||||
assert.deepStrictEqual(encoder.pending(), str2buf('PRIVMSG #channel hello\r\n'))
|
||||
})
|
||||
|
||||
it('Should pop of a partial line', () => {
|
||||
const encoder = new StatefulEncoder()
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
encoder.push(line)
|
||||
encoder.pop(str2buf('PRIVMSG #channel hello').length)
|
||||
|
||||
assert.deepStrictEqual(encoder.pending(), str2buf('\r\n'))
|
||||
})
|
||||
|
||||
it('Should only pop off first full line when more requested', () => {
|
||||
const encoder = new StatefulEncoder()
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
encoder.push(line)
|
||||
encoder.push(line)
|
||||
const lines = encoder.pop(str2buf('PRIVMSG #channel hello\r\nPRIVMSG').length)
|
||||
|
||||
assert.strictEqual(lines.length, 1)
|
||||
assert.deepStrictEqual(lines[0], line)
|
||||
})
|
||||
|
||||
it('Should return no lines on pop if there are no complete lines in requested size', () => {
|
||||
const encoder = new StatefulEncoder()
|
||||
const line = tokenise('PRIVMSG #channel hello')
|
||||
encoder.push(line)
|
||||
const lines = encoder.pop(1)
|
||||
assert.strictEqual(lines.length, 0)
|
||||
})
|
||||
|
||||
it('Should be clearable', () => {
|
||||
const encoder = new StatefulEncoder()
|
||||
encoder.push(tokenise('PRIVMSG #channel hello'))
|
||||
encoder.clear()
|
||||
|
||||
assert.deepStrictEqual(encoder.pending(), new Uint8Array())
|
||||
})
|
||||
})
|
|
@ -0,0 +1,112 @@
|
|||
/* eslint-env mocha */
|
||||
import assert from 'assert'
|
||||
import { Line, tokenise } from '../src'
|
||||
import { str2buf } from './stateful-decode.test'
|
||||
|
||||
describe('tokenise', () => {
|
||||
it('Should tokenise a line', () => {
|
||||
const line = tokenise('@id=123 :nick!user@host PRIVMSG #channel :hello world')
|
||||
assert.deepStrictEqual(line, new Line({
|
||||
tags: { id: '123' },
|
||||
source: 'nick!user@host',
|
||||
command: 'PRIVMSG',
|
||||
params: ['#channel', 'hello world']
|
||||
}))
|
||||
})
|
||||
|
||||
it('Should stop tokenising at nullbyte', () => {
|
||||
const line = tokenise(':nick!user@host PRIVMSG #channel :hello\x00 world')
|
||||
assert.deepStrictEqual(line.params, ['#channel', 'hello'])
|
||||
})
|
||||
|
||||
it('Should parse byte representation of a line', () => {
|
||||
const strLine = tokenise('@a=1 :n!u@h PRIVMSG #chan :hello word')
|
||||
const bufLine = tokenise(str2buf('@a=1 :n!u@h PRIVMSG #chan :hello word'))
|
||||
|
||||
assert.deepStrictEqual(strLine, bufLine)
|
||||
})
|
||||
|
||||
it('Should throw if missing command', () => {
|
||||
assert.throws(() => { tokenise(':n!u@h') }, { name: 'TypeError' })
|
||||
assert.throws(() => { tokenise('@tag=1 :n!u@h') }, { name: 'TypeError' })
|
||||
})
|
||||
|
||||
describe('tags', () => {
|
||||
it('Should tokenise a line without tags', () => {
|
||||
const line = tokenise('PRIVMSG #channel')
|
||||
assert.strictEqual(line.tags, undefined)
|
||||
})
|
||||
|
||||
it('Should tokenise an empty tag', () => {
|
||||
const line = tokenise('@id= PRIVMSG #channel')
|
||||
assert.deepStrictEqual(line.tags, { id: '' })
|
||||
})
|
||||
|
||||
it('Should tokenise a tag with only key', () => {
|
||||
const line = tokenise('@id PRIVMSG #channel')
|
||||
assert.deepStrictEqual(line.tags, { id: '' })
|
||||
})
|
||||
|
||||
it('Should unescape tags', () => {
|
||||
const line = tokenise('@id=1\\\\\\:\\r\\n\\s2 PRIVMSG #channel')
|
||||
assert.deepStrictEqual(line.tags, { id: '1\\;\r\n 2' })
|
||||
})
|
||||
|
||||
it('Should unescape tags with overlapping escapes', () => {
|
||||
const line = tokenise('@id=1\\\\\\s\\\\s PRIVMSG #channel')
|
||||
assert.deepStrictEqual(line.tags, { id: '1\\ \\s' })
|
||||
})
|
||||
|
||||
it('Should ignore trailing backslash', () => {
|
||||
const line = tokenise('@id=1\\ PRIVMSG #channel')
|
||||
assert.deepStrictEqual(line.tags, { id: '1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('source', () => {
|
||||
it('Should find source in message without tags', () => {
|
||||
const line = tokenise(':nick!user@host PRIVMSG #channel')
|
||||
assert.strictEqual(line.source, 'nick!user@host')
|
||||
})
|
||||
|
||||
it('Should find source in message with tags', () => {
|
||||
const line = tokenise('@id=123 :nick!user@host PRIVMSG #channel')
|
||||
assert.strictEqual(line.source, 'nick!user@host')
|
||||
})
|
||||
|
||||
it('Should be undefined if no source in message without tags', () => {
|
||||
const line = tokenise('PRIVMSG #channel')
|
||||
assert.strictEqual(line.source, undefined)
|
||||
})
|
||||
|
||||
it('Should be undefined if no source in message with tags', () => {
|
||||
const line = tokenise('@id=123 PRIVMSG #channel')
|
||||
assert.strictEqual(line.source, undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('command', () => {
|
||||
it('Should transform the command to uppercase', () => {
|
||||
const line = tokenise('privmsg #channel')
|
||||
assert.strictEqual(line.command, 'PRIVMSG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('params', () => {
|
||||
it('Should tokenise trailing paramerer', () => {
|
||||
const line = tokenise('PRIVMSG #channel :hello world')
|
||||
assert.deepStrictEqual(line.params, ['#channel', 'hello world'])
|
||||
})
|
||||
|
||||
it('Should tokenise message with only a trailing parameter', () => {
|
||||
const line = tokenise('PRIVMSG :hello world')
|
||||
assert.deepStrictEqual(line.params, ['hello world'])
|
||||
})
|
||||
|
||||
it('Should have an empty array fro messages without params', () => {
|
||||
const line = tokenise('PRIVMSG')
|
||||
assert.strictEqual(line.command, 'PRIVMSG')
|
||||
assert.deepStrictEqual(line.params, [])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": [
|
||||
"dist",
|
||||
"tests",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue