├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .npmignore ├── LICENSE.txt ├── README.md ├── babel.config.js ├── docs ├── clientapi.md ├── events.md └── ircv3.md ├── examples └── bot.js ├── package.json ├── src ├── channel.js ├── client.js ├── commands │ ├── command.js │ ├── handler.js │ ├── handlers │ │ ├── channel.js │ │ ├── generics.js │ │ ├── messaging.js │ │ ├── misc.js │ │ ├── registration.js │ │ └── user.js │ ├── index.js │ └── numerics.js ├── connection.js ├── helpers.js ├── index.js ├── irclineparser.js ├── ircmessage.js ├── linebreak.js ├── messagetags.js ├── networkinfo.js ├── transports │ ├── default.js │ ├── default_browser.js │ ├── net.js │ └── websocket.js └── user.js ├── test ├── casefolding.js ├── commands │ └── handlers │ │ └── misc.test.js ├── helper.test.js ├── ircLineParser.test.js ├── messagetags.js ├── mocks.js ├── networkinfo.test.js ├── servertime.js ├── setEncoding.js └── stringToChunks.js ├── webpack.config.js └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'standard' 6 | ], 7 | parserOptions: { 8 | sourceType: 'script', 9 | }, 10 | env: { 11 | 'browser': true, 12 | 'node': true, 13 | }, 14 | 'rules': { 15 | 'camelcase': 0, 16 | 'comma-dangle': 0, 17 | 'indent': ['error', 4], 18 | 'new-cap': 0, 19 | 'no-shadow': ['error'], 20 | 'no-var': ['error'], 21 | 'operator-linebreak': ['error', 'after'], 22 | 'semi': ['error', 'always'], 23 | 'space-before-function-paren': ['error', 'never'], 24 | 'standard/no-callback-literal': 0, 25 | 'n/no-callback-literal': 0, 26 | "object-shorthand": 0, 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Release workflow 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20.x' 20 | registry-url: 'https://registry.npmjs.org/' 21 | 22 | - name: Install 23 | run: yarn --frozen-lockfile --non-interactive 24 | 25 | - name: Publish 26 | run: yarn publish 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [16.x, 18.x, 20.x, 22.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'yarn' 22 | 23 | - name: Install dependencies 24 | run: yarn --frozen-lockfile --non-interactive --prefer-offline 25 | 26 | - name: Test 27 | run: yarn test 28 | 29 | - name: Lint 30 | run: yarn lint 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /node_modules/ 3 | /dist/ 4 | /*.sublime-project 5 | /*.sublime-workspace 6 | /irc-framework-*.tgz 7 | /.nyc_output/ 8 | /yarn-error.log 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /docs/ 3 | /examples/ 4 | /node_modules/ 5 | /test/ 6 | /*.sublime-project 7 | /*.sublime-workspace 8 | /irc-framework-*.tgz 9 | /babel.config.js 10 | /webpack.config.js 11 | /.* 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Darren Whitlen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # irc-framework 2 | A better IRC framework for node.js. For bots and full clients. Read the [documentation](https://github.com/kiwiirc/irc-framework/blob/master/docs/clientapi.md). 3 | 4 | ### Aims 5 | * Lightweight 6 | * Performant 7 | * Very easy to get going out of the box 8 | * Grows as needed for larger applications 9 | * IRCv3 compliant 10 | * Multiple (+ auto detected) encoding support 11 | * Complete test suite 12 | 13 | 14 | #### A simple and low-boilerplate framework to build IRC bots. 15 | ~~~javascript 16 | var bot = new IRC.Client(); 17 | bot.connect({ 18 | host: 'irc.freenode.net', 19 | port: 6667, 20 | nick: 'prawnsbot' 21 | }); 22 | 23 | bot.on('message', function(event) { 24 | if (event.message.indexOf('hello') === 0) { 25 | event.reply('Hi!'); 26 | } 27 | 28 | if (event.message.match(/^!join /)) { 29 | var to_join = event.message.split(' ')[1]; 30 | event.reply('Joining ' + to_join + '..'); 31 | bot.join(to_join); 32 | } 33 | }); 34 | 35 | 36 | // Or a quicker to match messages... 37 | bot.matchMessage(/^hi/, function(event) { 38 | event.reply('hello there!'); 39 | }); 40 | ~~~ 41 | 42 | #### Channel/buffer objects. Great for building clients 43 | ~~~javascript 44 | var bot = new IRC.Client(); 45 | bot.connect({ 46 | host: 'irc.freenode.net', 47 | port: 6667, 48 | nick: 'prawnsbot' 49 | }); 50 | 51 | var buffers = []; 52 | bot.on('registered', function() { 53 | var channel = bot.channel('#prawnsalad'); 54 | buffers.push(channel); 55 | 56 | channel.join(); 57 | channel.say('Hi!'); 58 | 59 | channel.updateUsers(function() { 60 | console.log(channel.users); 61 | }); 62 | 63 | // Or you could even stream the channel messages elsewhere 64 | var stream = channel.stream(); 65 | stream.pipe(process.stdout); 66 | }); 67 | ~~~ 68 | 69 | 70 | #### Middleware 71 | ~~~javascript 72 | function ExampleMiddleware() { 73 | return function(client, raw_events, parsed_events) { 74 | parsed_events.use(theMiddleware); 75 | } 76 | 77 | 78 | function theMiddleware(command, event, client, next) { 79 | if (command === 'registered') { 80 | if (client.options.nickserv) { 81 | var options = client.options.nickserv; 82 | client.say('nickserv', 'identify ' + options.account + ' ' + options.password); 83 | } 84 | } 85 | 86 | if (command === 'message' && client.caseCompare(event.event.nick, 'nickserv')) { 87 | // Handle success/retries/failures 88 | } 89 | 90 | next(); 91 | } 92 | } 93 | 94 | 95 | var irc_bot = new IRC.Client(); 96 | irc_bot.use(ExampleMiddleware()); 97 | ~~~ 98 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | forceAllTransforms: true, 5 | useBuiltIns: 'usage', 6 | corejs: 3, 7 | }], 8 | ], 9 | } 10 | -------------------------------------------------------------------------------- /docs/clientapi.md: -------------------------------------------------------------------------------- 1 | ### irc-framework Client instance API 2 | 3 | Inherits [EventEmitter](https://nodejs.org/api/events.html). Read more on the Events available [here](https://github.com/kiwiirc/irc-framework/blob/master/docs/events.md). 4 | 5 | #### Constructor 6 | ~~~javascript 7 | new Irc.Client({ 8 | nick: 'ircbot', 9 | username: 'ircbot', 10 | gecos: 'ircbot', 11 | encoding: 'utf8', 12 | version: 'node.js irc-framework', 13 | enable_chghost: false, 14 | enable_echomessage: false, 15 | auto_reconnect: true, 16 | auto_reconnect_max_wait: 300000, 17 | auto_reconnect_max_retries: 3, 18 | ping_interval: 30, 19 | ping_timeout: 120, 20 | sasl_disconnect_on_fail: false, 21 | account: { 22 | account: 'username', 23 | password: 'account_password', 24 | }, 25 | webirc: { 26 | password: '', 27 | username: '*', 28 | hostname: 'users.host.isp.net', 29 | ip: '1.1.1.1', 30 | options: { 31 | secure: true, 32 | 'local-port': 6697, 33 | 'remote-port': 21726, 34 | }, 35 | }, 36 | client_certificate: { 37 | private_key: '-----BEGIN RSA PRIVATE KEY-----[...]', 38 | certificate: '-----BEGIN CERTIFICATE-----[...]', 39 | }, 40 | }); 41 | ~~~ 42 | 43 | #### Configuration 44 | ##### `account` 45 | To accommodate backwards compatibility support, the `account` configuration can behave in different ways depending what data types its provided. 46 | 47 | When the `account` property is falsy, `options.nick` and `options.password` will be used for SASL Auth. 48 | 49 | If the `account` property is an object, `account.account` and `account.password` will be used for SASL Auth. 50 | 51 | To Completely disable SASL Auth, set `account` to an empty object, eg: `account: {}`. 52 | 53 | 54 | #### Properties 55 | ##### `.connected` 56 | If connected to the IRC network and successfully registered 57 | 58 | ##### `.user` 59 | Once connected to an IRC network, this object will have these properties: 60 | * `.nick` The current nick you are currently using 61 | * `.username` Your username (ident) that the network sees you as using 62 | * `.gecos` Your current gecos (realname) 63 | * `.host` On supported servers, the hostname that the networksees you as using 64 | * `.away` Your current away status. Empty for not away 65 | * `.modes` A Set() instance with your current user modes 66 | 67 | 68 | #### Methods 69 | ##### `.requestCap('twitch.tv/membership')` 70 | Request an extra IRCv3 capability 71 | 72 | ##### `.use(middleware_fn())` 73 | Add middleware to handle the events for the client instance 74 | 75 | ##### `.connect([connect_options])` 76 | Start connecting to the IRC network. If `connect_options` is provided it will 77 | override any options given to the constructor. 78 | 79 | ##### `.raw(raw_data_line)` 80 | Send a raw line to the IRC server 81 | 82 | ##### `.rawString('JOIN', '#channel')` 83 | ##### `.rawString(['JOIN', '#channel'])` 84 | Generate a formatted line from either an array or arguments to be sent to the 85 | IRC server. 86 | 87 | ##### `.quit([quit_message])` 88 | Quit from the IRC network with the given message 89 | 90 | ##### `.ping([message])` 91 | Ping the IRC server to show you're still alive. 92 | 93 | ##### `.changeNick(nick)` 94 | Attempt to change the clients nick on the network 95 | 96 | ##### `.say(target, message [, tags])` 97 | Send a message to the target, optionally with tags. 98 | 99 | ##### `.notice(target, message [, tags])` 100 | Send a notice to the target, optionally with tags. 101 | 102 | ##### `.tagmsg(target, tags)` 103 | Send a tagged message without content to the target 104 | 105 | ##### `.join(channel [, key])` 106 | Join a channel, optionally with a key/password. 107 | 108 | ##### `.part(channel [, message])` 109 | Part/leave a channel with an optional parting message. 110 | 111 | ##### `.setTopic(channel, newTopic)` 112 | Set the topic of a channel, if newTopic is falsy or only whitespace then `.clearTopic()` will be called. 113 | 114 | ##### `.clearTopic(channel)` 115 | Remove the topic of a channel. 116 | 117 | ##### `.ctcpRequest(target, type [, paramN])` 118 | Send a CTCP request to target with any number of parameters. 119 | 120 | ##### `.ctcpResponse(target, type [, paramN])` 121 | Send a CTCP response to target with any number of parameters. 122 | 123 | ##### `.action(target, message)` 124 | Send an action message (typically /me) to a target. 125 | 126 | ##### `.whois(nick [, cb])` 127 | Receive information about a user on the network if they exist. Optionally calls 128 | `cb(event)` with the result if provided. 129 | 130 | ##### `.who(target [, cb])` 131 | Receive a list of users on the network that matches the target. The target may be 132 | a channel or wildcard nick. Optionally calls `cb(event)` with the result if 133 | provided. Multiple calls to this function are queued up and run one at a time in 134 | order. 135 | 136 | ##### `.list([, paramN])` 137 | Request that the IRC server sends a list of available channels. Extra parameters 138 | will be sent. 139 | 140 | ##### `.channel(channel_name, [key])` 141 | Create a channel object with the following methods: 142 | * `say(message)` 143 | * `notice(message)` 144 | * `action(message)` 145 | * `part([part_message])` 146 | * `join([key])` 147 | 148 | ##### `.caseCompare(string1, string2)` 149 | Compare two strings using the networks casemapping setting. 150 | 151 | ##### `.caseUpper(string)` 152 | Uppercase the characters in string using the networks casemapping setting. 153 | 154 | ##### `.caseLower(string)` 155 | Lowercase the characters in string using the networks casemapping setting. 156 | 157 | ##### `.match(match_regex, cb[, message_type])` 158 | Call `cb()` when any incoming message matches `match_regex`. 159 | 160 | ##### `.matchNotice(match_regex, cb)` 161 | Call `cb()` when an incoming notice message matches `match_regex`. 162 | 163 | ##### `.matchMessage(match_regex, cb)` 164 | Call `cb()` when an incoming plain message matches `match_regex`. 165 | 166 | ##### `.matchAction(match_regex, cb)` 167 | Call `cb()` when an incoming action message matches `match_regex`. 168 | 169 | ##### `.addMonitor(target)` 170 | Add `target` to the list of targets being monitored. `target` can be a comma-separated list of nicks. 171 | 172 | ##### `.removeMonitor(target)` 173 | Remove `target` from the list of targets being monitored. `target` can be a comma-separated list of nicks. 174 | 175 | ##### `.clearMonitor()` 176 | Clear the list of targets being monitored. 177 | 178 | ##### `.monitorlist([cb])` 179 | Return the current list of targets being monitored. Optionally calls `cb()` with the result. 180 | 181 | ##### `.queryMonitor()` 182 | Query the current list of targets being monitored. Will emit `users online` with targets that are online, 183 | and `users offline` with targets that are offline. 184 | -------------------------------------------------------------------------------- /docs/events.md: -------------------------------------------------------------------------------- 1 | ### Events available on an IRC client 2 | 3 | Raw IRC events are parsed and turned into javascript friendly event objects. IRC events that are not parsed are triggered using their IRC command name. 4 | 5 | You can bind to an event via the `.on` method. 6 | ~~~javascript 7 | var client = new IRC.Client(); 8 | client.connect({ 9 | host: 'irc.freenode.net', 10 | port: 6667, 11 | nick: 'prawnsbot' 12 | }); 13 | 14 | client.on('registered', function(event) { 15 | // ... 16 | }); 17 | ~~~ 18 | 19 | Or if you want to use them in your middleware... 20 | ~~~javascript 21 | function MyMiddleware() { 22 | return function(client, raw_events, parsed_events) { 23 | parsed_events.use(theMiddleware); 24 | } 25 | 26 | 27 | function theMiddleware(command, event, client, next) { 28 | if (command === 'registered') { 29 | // ... 30 | } 31 | 32 | next(); 33 | } 34 | } 35 | ~~~ 36 | 37 | 38 | #### Registration 39 | **registered** / **connected** 40 | 41 | Once the client has connected and successfully registered on the IRC network. This is a good place to start joining channels. 42 | ~~~javascript 43 | { 44 | nick: nick 45 | } 46 | ~~~ 47 | 48 | 49 | **reconnecting** 50 | 51 | The client has disconnected from the network and will now automatically re-connect (if enabled). 52 | ~~~javascript 53 | { } 54 | ~~~ 55 | 56 | 57 | **close** 58 | 59 | The client has disconnected from the network and failed to auto reconnect (if enabled). 60 | ~~~javascript 61 | { } 62 | ~~~ 63 | 64 | 65 | **socket close** 66 | 67 | The client has disconnected from the network. 68 | ~~~javascript 69 | { } 70 | ~~~ 71 | 72 | 73 | **socket connected** 74 | 75 | The client has a connected socket to the network. Network registration will automatically start at this point. 76 | ~~~javascript 77 | { } 78 | ~~~ 79 | 80 | 81 | **raw socket connected** 82 | 83 | The client has a raw connected socket to the network but not yet completed any TLS handshakes yet. This is a good place to read any TCP port information for things like identd. 84 | ~~~javascript 85 | { } 86 | ~~~ 87 | 88 | 89 | **server options** 90 | ~~~javascript 91 | { 92 | options: { ... }, 93 | cap: { ... } 94 | } 95 | ~~~ 96 | 97 | 98 | #### Raw connection and debugging 99 | **raw** 100 | 101 | A valid raw line sent or received from the IRC server. 102 | ~~~javascript 103 | { 104 | line: ':server.ircd.net 265 prawnsalad :Current Local Users: 214 Max: 411', 105 | from_server: true 106 | } 107 | ~~~ 108 | 109 | 110 | **unknown command** 111 | 112 | This event gets emitted whenever no handler is registered for an IRC commmand. 113 | The payload is an instance of an IrcCommand. 114 | ~~~javascript 115 | IrcCommand { 116 | command: '250', 117 | params: [ 118 | 'prawnsbot', 119 | 'Highest connection count: 2375 (2374 clients) (27822 connections received)' 120 | ], 121 | tags: {}, 122 | prefix: 'copper.libera.chat', 123 | nick: '', 124 | ident: '', 125 | hostname: 'copper.libera.chat' 126 | } 127 | ~~~ 128 | 129 | 130 | **debug** 131 | 132 | Debugging messages. 133 | ~~~javascript 134 | 'Socket fully connected' 135 | ~~~ 136 | 137 | #### Channels 138 | **channel info** 139 | ~~~javascript 140 | { 141 | channel: '#channel', 142 | modes: [ ... ], 143 | raw_modes: '+o', 144 | raw_params: 'prawnsalad', 145 | tags: { ... } 146 | } 147 | ~~~ 148 | 149 | 150 | **channel list start** 151 | ~~~javascript 152 | { } 153 | ~~~ 154 | 155 | 156 | **channel list** 157 | ~~~javascript 158 | [ 159 | { 160 | channel: '#channel1', 161 | num_users: 123, 162 | topic: 'My topic', 163 | tags: {}, 164 | }, 165 | { 166 | channel: '#channel2', 167 | num_users: 456, 168 | topic: 'My topic', 169 | tags: {}, 170 | }, 171 | ... 172 | ] 173 | ~~~ 174 | 175 | 176 | **channel list end** 177 | ~~~javascript 178 | { } 179 | ~~~ 180 | 181 | 182 | **channel info** 183 | ~~~javascript 184 | { 185 | channel: '#channel', 186 | created_at: 000000000, 187 | tags: { ... } 188 | } 189 | ~~~ 190 | 191 | 192 | **channel info** 193 | ~~~javascript 194 | { 195 | channel: '#channel', 196 | url: 'http://channel-website.com/', 197 | tags: { ... } 198 | } 199 | ~~~ 200 | 201 | 202 | **wholist** 203 | ~~~javascript 204 | { 205 | target: '#channel', 206 | users: [ ... ], 207 | tags: { ... } 208 | } 209 | ~~~ 210 | 211 | 212 | **userlist** 213 | ~~~javascript 214 | { 215 | channel: '#channel', 216 | users: [ ... ], 217 | tags: { ... } 218 | } 219 | ~~~ 220 | 221 | 222 | **invitelist** 223 | ~~~javascript 224 | { 225 | channel: '#channel', 226 | invites: [ ... ], 227 | tags: { ... } 228 | } 229 | ~~~ 230 | 231 | 232 | **banlist** 233 | ~~~javascript 234 | { 235 | channel: '#channel', 236 | bans: [ ... ], 237 | tags: { ... } 238 | } 239 | ~~~ 240 | 241 | 242 | **exceptlist** 243 | ~~~javascript 244 | { 245 | channel: '#channel', 246 | excepts: [ ... ], 247 | tags: { ... } 248 | } 249 | ~~~ 250 | 251 | 252 | **topic** 253 | ~~~javascript 254 | { 255 | channel: '#channel', 256 | // topic will be empty if one is not set 257 | topic: 'The channel topic', 258 | 259 | // If the topic has just been changed, the following is also available 260 | nick: 'prawnsalad', 261 | time: 000000000 262 | } 263 | ~~~ 264 | 265 | 266 | **topicsetby** 267 | ~~~javascript 268 | { 269 | nick: 'prawnsalad', 270 | ident: 'prawnsalad', 271 | hostname: 'unaffiliated/prawnsalad', 272 | channel: '#channel', 273 | when: 000000000 274 | } 275 | ~~~ 276 | 277 | 278 | **join** 279 | 280 | The account name will only be available on supported networks. 281 | ~~~javascript 282 | { 283 | nick: 'prawnsalad', 284 | ident: 'prawn', 285 | hostname: 'manchester.isp.net', 286 | gecos: 'prawns real name', 287 | channel: '#channel', 288 | time: 000000000, 289 | account: 'account_name' 290 | } 291 | ~~~ 292 | 293 | 294 | **part** 295 | ~~~javascript 296 | { 297 | nick: 'prawnsalad', 298 | ident: 'prawn', 299 | hostname: 'manchester.isp.net', 300 | channel: '#channel', 301 | message: 'My part message', 302 | time: 000000000 303 | } 304 | ~~~ 305 | 306 | 307 | **kick** 308 | ~~~javascript 309 | { 310 | kicked: 'someabuser', 311 | nick: 'prawnsalad', 312 | ident: 'prawn', 313 | hostname: 'manchester.isp.net', 314 | channel: '#channel', 315 | message: 'Reason why someabuser was kicked', 316 | time: 000000000 317 | } 318 | ~~~ 319 | 320 | 321 | **quit** 322 | ~~~javascript 323 | { 324 | nick: 'prawnsalad', 325 | ident: 'prawn', 326 | hostname: 'manchester.isp.net', 327 | message: 'Reason why I'm leaving IRC, 328 | time: 000000000 329 | } 330 | ~~~ 331 | 332 | 333 | **invited** 334 | ~~~javascript 335 | { 336 | nick: 'inviteduser', 337 | channel: '#channel' 338 | } 339 | ~~~ 340 | 341 | 342 | 343 | #### Messaging 344 | **notice** 345 | 346 | Also triggers a **message** event with .type = 'notice'. from_server indicates if this notice was 347 | sent from the server or a user. 348 | ~~~javascript 349 | { 350 | from_server: false, 351 | nick: 'prawnsalad', 352 | ident: 'prawn', 353 | hostname: 'manchester.isp.net', 354 | target: '#channel', 355 | group: '@', 356 | message: 'A message to all channel ops', 357 | tags: [], 358 | time: 000000000, 359 | account: 'account_name' 360 | } 361 | ~~~ 362 | 363 | 364 | **action** 365 | 366 | Also triggers a **message** event with .type = 'action' 367 | ~~~javascript 368 | { 369 | nick: 'prawnsalad', 370 | ident: 'prawn', 371 | hostname: 'manchester.isp.net', 372 | target: '#channel', 373 | message: 'slaps someuser around a bit with a large trout', 374 | tags: [], 375 | time: 000000000, 376 | account: 'account_name' 377 | } 378 | ~~~ 379 | 380 | 381 | **privmsg** 382 | 383 | Also triggers a **message** event with .type = 'privmsg' 384 | ~~~javascript 385 | { 386 | nick: 'prawnsalad', 387 | ident: 'prawn', 388 | hostname: 'manchester.isp.net', 389 | target: '#channel', 390 | message: 'Hello everybody', 391 | tags: [], 392 | time: 000000000, 393 | account: 'account_name' 394 | } 395 | ~~~ 396 | 397 | **tagmsg** 398 | 399 | ~~~javascript 400 | { 401 | nick: 'prawnsalad', 402 | ident: 'prawn', 403 | hostname: 'manchester.isp.net', 404 | target: '#channel', 405 | tags: { 406 | example: 'hello' 407 | }, 408 | time: 000000000, 409 | account: 'account_name' 410 | } 411 | ~~~ 412 | 413 | **ctcp response** 414 | ~~~javascript 415 | { 416 | nick: 'prawnsalad', 417 | ident: 'prawn', 418 | hostname: 'manchester.isp.net', 419 | target: 'someuser', 420 | message: 'VERSION kiwiirc', 421 | time: 000000000, 422 | account: 'account_name' 423 | } 424 | ~~~ 425 | 426 | 427 | **ctcp request** 428 | 429 | The `VERSION` CTCP is handled internally and will not trigger this event, unless you set the 430 | `version` option to `null` 431 | ~~~javascript 432 | { 433 | nick: 'prawnsalad', 434 | ident: 'prawn', 435 | hostname: 'manchester.isp.net', 436 | target: 'someuser', 437 | type: 'VERSION', 438 | message: 'VERSION and remaining text', 439 | time: 000000000, 440 | account: 'account_name' 441 | } 442 | ~~~ 443 | 444 | 445 | **wallops** 446 | ~~~javascript 447 | { 448 | from_server: false, 449 | nick: 'prawnsalad', 450 | ident: 'prawn', 451 | hostname: 'manchester.isp.net', 452 | message: 'This is a server-wide message', 453 | account: 'account_name' 454 | } 455 | ~~~ 456 | 457 | 458 | #### Users 459 | **nick** 460 | ~~~javascript 461 | { 462 | nick: 'prawnsalad', 463 | ident: 'prawn', 464 | hostname: 'isp.manchester.net', 465 | new_nick: 'prawns_new_nick', 466 | time: 000000000 467 | } 468 | ~~~ 469 | 470 | 471 | **account** 472 | 473 | `account` will be `false` if the user has logged out. 474 | ~~~javascript 475 | { 476 | nick: 'prawnsalad', 477 | ident: 'prawn', 478 | hostname: 'isp.manchester.net', 479 | account: 'prawns_account_name', 480 | time: 000000000 481 | } 482 | ~~~ 483 | 484 | 485 | **user info** 486 | 487 | May be sent upon connecting to a server or when a `MODE ` was executed. 488 | Corresponds to 'RPL_UMODEIS'. 489 | ~~~javascript 490 | { 491 | nick: 'prawnsalad', 492 | raw_modes: '+Ri', 493 | tags: {} 494 | } 495 | ~~~ 496 | 497 | 498 | 499 | **away** 500 | 501 | `self` will be `true` if this is a response to your `away` command. 502 | ~~~javascript 503 | { 504 | self: false, 505 | nick: 'prawnsalad', 506 | message: 'Time to go eat some food.', 507 | time: 000000000 508 | } 509 | ~~~ 510 | 511 | 512 | **back** 513 | 514 | `self` will be `true` if this is a response to your `away` command. 515 | ~~~javascript 516 | { 517 | self: false, 518 | nick: 'prawnsalad', 519 | message: 'You are now back', 520 | time: 000000000 521 | } 522 | ~~~ 523 | 524 | 525 | **monitorlist** 526 | ~~~javascript 527 | { 528 | nicks: ['nick1', 'nick2', 'nick3'] 529 | } 530 | ~~~ 531 | 532 | 533 | **nick in use** 534 | ~~~javascript 535 | { 536 | nick: 'attempted_nick', 537 | reason: 'That nickname is already in use' 538 | } 539 | ~~~ 540 | 541 | 542 | **nick invalid** 543 | ~~~javascript 544 | { 545 | nick: 'attempted@nick', 546 | reason: 'That is an invalid nick' 547 | } 548 | ~~~ 549 | 550 | 551 | **users online** 552 | ~~~javascript 553 | { 554 | nicks: ['nick1', 'nick2', 'nick3'], 555 | } 556 | ~~~ 557 | 558 | 559 | **users offline** 560 | ~~~javascript 561 | { 562 | nicks: ['nick1', 'nick2', 'nick3'], 563 | } 564 | ~~~ 565 | 566 | 567 | 568 | **whois** 569 | 570 | Not all of these options will be available. Some will be missing depending on the network. 571 | ~~~javascript 572 | { 573 | away: 'away message', 574 | nick: 'prawnsalad', 575 | ident: 'prawn', 576 | hostname: 'manchester.isp.net', 577 | actual_ip: 'sometimes set when using webirc, could be the same as actual_hostname', 578 | actual_hostname: 'sometimes set when using webirc', 579 | real_name: 'A real prawn', 580 | helpop: 'is available for help', 581 | bot: 'is a bot', 582 | server: 'irc.server.net', 583 | server_info: '', 584 | operator: 'is an operator', 585 | channels: 'is on these channels', 586 | modes: '', 587 | idle: 'idle for 34 secs', 588 | logon: 'logged on at X', 589 | registered_nick: 'prawnsalad', 590 | account: 'logged on account', 591 | secure: 'is using SSL/TLS', 592 | special: '' 593 | } 594 | ~~~ 595 | 596 | 597 | **whowas** 598 | 599 | The response root includes the latest data from `whowas[0]` to maintain backwards compatibility. 600 | The `whowas` array contains all RPL_WHOWASUSER responses with the newest being first. 601 | 602 | If the requested user was not found, `error` will contain 'no_such_nick'. 603 | 604 | > Note: The available data can vary depending on the network. 605 | 606 | ~~~javascript 607 | { 608 | nick: 'prawnsalad', 609 | ident: 'prawn', 610 | hostname: 'manchester.isp.net', 611 | actual_ip: 'sometimes set when using webirc, could be the same as actual_hostname', 612 | actual_hostname: 'sometimes set when using webirc', 613 | actual_username: 'prawn', 614 | real_name: 'A real prawn', 615 | server: 'irc.server.net', 616 | server_info: 'Thu Jun 14 09:15:51 2018', 617 | account: 'logged on account', 618 | error: '' 619 | whowas: [ 620 | { ... }, 621 | { ... }, 622 | ] 623 | } 624 | ~~~ 625 | 626 | 627 | **user updated** 628 | 629 | Only on supporting IRC servers with CHGHOST capabilities and 'enable_chghost' set in the connection options. 630 | ~~~javascript 631 | { 632 | nick: 'prawnsalad', 633 | ident: 'prawns_old_ident', 634 | hostname: 'prawns.old.hostname', 635 | new_ident: 'prawns_new_ident', 636 | new_hostname: 'prawns_new_host', 637 | time: time 638 | } 639 | ~~~ 640 | 641 | 642 | 643 | #### Misc 644 | **motd** 645 | ~~~javascript 646 | { 647 | motd: 'combined motd text which will contain newlines', 648 | tags: {}, 649 | } 650 | ~~~ 651 | 652 | **info** 653 | ~~~javascript 654 | { 655 | info: 'combined info text which will contain newlines (RPL_INFO)', 656 | tags: {}, 657 | } 658 | ~~~ 659 | 660 | **help** 661 | ~~~javascript 662 | { 663 | help: 'combined help text which will contain newlines (RPL_HELPTXT)', 664 | tags: {}, 665 | } 666 | ~~~ 667 | 668 | **batch start** 669 | 670 | On capable networks a set of commands may be batched together. The commands will be 671 | executed automatically directly after this event as a transaction, each with a tag 672 | `batch` matching this `event.id` value. 673 | 674 | A `batch start ` event is also triggered. 675 | ~~~javascript 676 | { 677 | id: 1, 678 | type: 'chathistory', 679 | params: [], 680 | commands: [] 681 | } 682 | ~~~ 683 | 684 | **batch end** 685 | 686 | After a `batch start` event has been triggered along with all its commands, this event 687 | will be triggered directly after. 688 | 689 | A `batch end ` event is also triggered. 690 | ~~~javascript 691 | { 692 | id: 1, 693 | type: 'chathistory', 694 | params: [], 695 | commands: [] 696 | } 697 | ~~~ 698 | 699 | 700 | **cap ls**, **cap ack**, **cap nak**, **cap list**, **cap new**, **cap del** 701 | 702 | Triggered for each `CAP` command, lists the sent capabilities list. 703 | 704 | ~~~javascript 705 | { 706 | command: 'LS', 707 | capabilities: { 708 | 'sts': 'port=6697' 709 | } 710 | } 711 | ~~~ 712 | 713 | ### SASL 714 | 715 | **loggedin** / **loggedout** 716 | 717 | Trigged when the user logs in or out, can be triggered by either SASL or NickServ auth 718 | 719 | ~~~javascript 720 | { 721 | nick: 'prawnsalad' 722 | ident: 'prawnsalad', 723 | hostname: 'manchester.isp.net', 724 | account: 'prawnsalad', 725 | time: 000000000, 726 | tags: { ... } 727 | }; 728 | ~~~ 729 | 730 | 731 | **sasl failed** 732 | 733 | Triggered when an SASL auth fails 734 | 735 | "reason" can be one of the following: 736 | * `fail` - ERR_SASLFAIL (904) 737 | * `too_long` - ERR_SASLTOOLONG (905) 738 | * `nick_locked` - ERR_NICKLOCKED (902) 739 | * `unsupported_mechanism` - A mechanism was requested that the server does not support 740 | * `capability_missing` - An account was provided with the connection, but the server does not offer the SASL capability 741 | 742 | ~~~javascript 743 | { 744 | reason: 'fail', 745 | 746 | // The following properties are only included when the event was triggered from an IRC event 747 | message: 'SASL authentication failed', 748 | nick: 'prawnsalad', 749 | time: 000000000, 750 | tags: { ... }, 751 | } 752 | ~~~ 753 | -------------------------------------------------------------------------------- /docs/ircv3.md: -------------------------------------------------------------------------------- 1 | ### IRCv3 Support 2 | 3 | #### IRCv3.1 support 4 | * CAP 5 | * sasl 6 | * multi-prefix 7 | * account-notify 8 | * away-notify 9 | * extended-join 10 | 11 | #### IRCv3.2 support 12 | * CAP 13 | * account-tag 14 | * batch 15 | * chghost 16 | * echo-message 17 | * invite-notify 18 | * sasl 19 | * server-time 20 | * userhost-in-names 21 | * message-tags 22 | 23 | #### Extra notes 24 | * chghost 25 | 26 | Only enabled if the client `enable_chghost` option is `true`. Clients may need to specifically handle this to update their state if the username or hostname suddenly changes. 27 | 28 | * echo-message 29 | 30 | Only enabled if the client `enable_echomessage` option is `true`. Clients may not be expecting their own messages being echoed back by default so it must be enabled manually. 31 | Until IRCv3 labelled replies are available, sent message confirmations will not be available. More information on the echo-message limitations can be found here https://github.com/ircv3/ircv3-specifications/pull/284/files 32 | -------------------------------------------------------------------------------- /examples/bot.js: -------------------------------------------------------------------------------- 1 | const IRC = require('../'); 2 | 3 | /** 4 | * Example middleware structure to handle NickServ authentication 5 | * Accepts a `nickserv` object from the client connect() options 6 | */ 7 | function NickservMiddleware() { // eslint-disable-line 8 | return function(client, raw_events, parsed_events) { 9 | raw_events.use(theMiddleware); 10 | }; 11 | 12 | function theMiddleware(command, event, client, next) { 13 | if (command === '005') { 14 | if (client.options.nickserv) { 15 | const options = client.options.nickserv; 16 | client.say('nickserv', 'identify ' + options.account + ' ' + options.password); 17 | } 18 | } 19 | 20 | if (command === 'PRIVMSG' && client.caseCompare(event.params[0], 'nickserv')) { 21 | // Handle success/retries/failures 22 | } 23 | 24 | next(); 25 | } 26 | } 27 | 28 | function MyIrcMiddleware() { 29 | return function(client, raw_events, parsed_events) { 30 | parsed_events.use(theMiddleware); 31 | client.requestCap('kiwiirc.com/user'); 32 | }; 33 | 34 | function theMiddleware(command, event, client, next) { 35 | // console.log('[MyMiddleware]', command, event); 36 | if (command === 'message' && event.message.indexOf('omg') === 0) { 37 | event.message += '!!!!!'; 38 | event.reply('> appended extra points'); 39 | } 40 | 41 | next(); 42 | } 43 | } 44 | 45 | const bot = new IRC.Client(); 46 | bot.use(MyIrcMiddleware()); 47 | bot.connect({ 48 | host: 'irc.snoonet.org', 49 | nick: 'prawnsbot' 50 | }); 51 | bot.on('registered', function() { 52 | console.log('Connected!'); 53 | bot.join('#prawnsalad'); 54 | // var channel = bot.channel('#prawnsalad'); 55 | // channel.join(); 56 | // channel.say('Hi!'); 57 | // channel.updateUsers(function() { 58 | // console.log(channel.users); 59 | // }); 60 | }); 61 | 62 | bot.on('close', function() { 63 | console.log('Connection close'); 64 | }); 65 | 66 | bot.on('message', function(event) { 67 | console.log('<' + event.target + '>', event.message); 68 | if (event.message.indexOf('whois') === 0) { 69 | bot.whois(event.message.split(' ')[1]); 70 | } 71 | }); 72 | 73 | bot.matchMessage(/^!hi/, function(event) { 74 | event.reply('sup'); 75 | }); 76 | 77 | bot.on('whois', function(event) { 78 | console.log(event); 79 | }); 80 | 81 | bot.on('join', function(event) { 82 | console.log('user joined', event); 83 | }); 84 | 85 | bot.on('userlist', function(event) { 86 | console.log('userlist for', event.channel, event.users); 87 | }); 88 | 89 | bot.on('part', function(event) { 90 | console.log('user part', event); 91 | }); 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "irc-framework", 3 | "version": "4.14.0", 4 | "description": "A better IRC framework for node.js", 5 | "main": "src/", 6 | "browser": "dist/browser/src/", 7 | "dependencies": { 8 | "buffer": "^6.0.3", 9 | "core-js": "^3.38.1", 10 | "eventemitter3": "^5.0.1", 11 | "grapheme-splitter": "^1.0.4", 12 | "iconv-lite": "^0.6.3", 13 | "isomorphic-textencoder": "^1.0.1", 14 | "lodash": "^4.17.21", 15 | "middleware-handler": "^0.2.0", 16 | "regenerator-runtime": "^0.14.1", 17 | "socks": "^2.8.3", 18 | "stream-browserify": "^3.0.0", 19 | "util": "^0.12.5" 20 | }, 21 | "devDependencies": { 22 | "@babel/cli": "^7.25.6", 23 | "@babel/core": "^7.25.2", 24 | "@babel/preset-env": "^7.25.4", 25 | "chai": "^4.5.0", 26 | "chai-subset": "^1.6.0", 27 | "compression-webpack-plugin": "^10.0.0", 28 | "eslint": "^8.57.0", 29 | "eslint-config-standard": "^17.1.0", 30 | "eslint-plugin-import": "^2.29.1", 31 | "eslint-plugin-n": "^15.7.0", 32 | "eslint-plugin-node": "^11.1.0", 33 | "eslint-plugin-promise": "^6.6.0", 34 | "mocha": "^10.7.3", 35 | "npm-run-all": "^4.1.5", 36 | "nyc": "^15.1.0", 37 | "shx": "^0.3.4", 38 | "sinon": "^15.2.0", 39 | "sinon-chai": "^3.7.0", 40 | "webpack": "^5.94.0", 41 | "webpack-cli": "^5.1.4" 42 | }, 43 | "scripts": { 44 | "test": "npm-run-all lint coverage", 45 | "lint": "eslint src/ examples/ test/", 46 | "unit-test": "mocha --recursive", 47 | "coverage": "nyc mocha -R dot test/ --recursive", 48 | "build": "npm-run-all build-browser-es5 build-browser-bundle", 49 | "build-browser-es5": "babel src/ -d dist/browser/src/ --delete-dir-on-start && shx mv ./dist/browser/src/transports/default_browser.js ./dist/browser/src/transports/default.js && shx rm ./dist/browser/src/transports/net.js", 50 | "build-browser-bundle": "webpack --config webpack.config.js", 51 | "prepare": "npm-run-all build" 52 | }, 53 | "repository": { 54 | "type": "git", 55 | "url": "git+https://github.com/kiwiirc/irc-framework.git" 56 | }, 57 | "keywords": [ 58 | "IRC", 59 | "bot", 60 | "messaging" 61 | ], 62 | "author": "prawnsalad", 63 | "license": "MIT", 64 | "bugs": { 65 | "url": "https://github.com/kiwiirc/irc-framework/issues" 66 | }, 67 | "homepage": "https://github.com/kiwiirc/irc-framework#readme" 68 | } 69 | -------------------------------------------------------------------------------- /src/channel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | partial: require('lodash/partial'), 5 | filter: require('lodash/filter'), 6 | find: require('lodash/find'), 7 | each: require('lodash/each'), 8 | pull: require('lodash/pull'), 9 | extend: require('lodash/extend'), 10 | }; 11 | 12 | const DuplexStream = require('stream').Duplex; 13 | 14 | module.exports = class IrcChannel { 15 | constructor(irc_client, channel_name, key) { 16 | this.irc_client = irc_client; 17 | this.name = channel_name; 18 | 19 | // TODO: Proxy channel related events from irc_bot to this instance 20 | 21 | this.say = _.partial(irc_client.say.bind(irc_client), channel_name); 22 | this.notice = _.partial(irc_client.notice.bind(irc_client), channel_name); 23 | // this.action = _.partial(irc_client.action.bind(irc_client), channel_name); 24 | this.part = _.partial(irc_client.part.bind(irc_client), channel_name); 25 | this.join = _.partial(irc_client.join.bind(irc_client), channel_name); 26 | this.mode = _.partial(irc_client.mode.bind(irc_client), channel_name); 27 | this.banlist = _.partial(irc_client.banlist.bind(irc_client), channel_name); 28 | this.ban = _.partial(irc_client.ban.bind(irc_client), channel_name); 29 | this.unban = _.partial(irc_client.unban.bind(irc_client), channel_name); 30 | 31 | this.users = []; 32 | irc_client.on('userlist', (event) => { 33 | if (irc_client.caseCompare(event.channel, this.name)) { 34 | this.users = event.users; 35 | } 36 | }); 37 | irc_client.on('join', (event) => { 38 | if (irc_client.caseCompare(event.channel, this.name)) { 39 | this.users.push(event); 40 | } 41 | }); 42 | irc_client.on('part', (event) => { 43 | if (irc_client.caseCompare(event.channel, this.name)) { 44 | this.users = _.filter(this.users, function(o) { 45 | return !irc_client.caseCompare(event.nick, o.nick); 46 | }); 47 | } 48 | }); 49 | irc_client.on('kick', (event) => { 50 | if (irc_client.caseCompare(event.channel, this.name)) { 51 | this.users = _.filter(this.users, function(o) { 52 | return !irc_client.caseCompare(event.kicked, o.nick); 53 | }); 54 | } 55 | }); 56 | irc_client.on('quit', (event) => { 57 | this.users = _.filter(this.users, function(o) { 58 | return !irc_client.caseCompare(event.nick, o.nick); 59 | }); 60 | }); 61 | irc_client.on('nick', (event) => { 62 | _.find(this.users, function(o) { 63 | if (irc_client.caseCompare(event.nick, o.nick)) { 64 | o.nick = event.new_nick; 65 | return true; 66 | } 67 | }); 68 | }); 69 | irc_client.on('mode', (event) => { 70 | /* event will be something like: 71 | { 72 | target: '#prawnsalad', 73 | nick: 'ChanServ', 74 | modes: [ { mode: '+o', param: 'prawnsalad' } ], 75 | time: undefined 76 | } 77 | */ 78 | 79 | if (!irc_client.caseCompare(event.target, this.name)) { 80 | return; 81 | } 82 | 83 | // There can be multiple modes set at once, loop through 84 | _.each(event.modes, mode => { 85 | // If this mode has a user prefix then we need to update the user object 86 | // eg. +o +h +v 87 | const user_prefix = _.find(irc_client.network.options.PREFIX, { 88 | mode: mode.mode[1], 89 | }); 90 | 91 | if (!user_prefix) { 92 | // TODO : manage channel mode changes 93 | } else { // It's a user mode 94 | // Find the user affected 95 | const user = _.find(this.users, u => 96 | irc_client.caseCompare(u.nick, mode.param) 97 | ); 98 | 99 | if (!user) { 100 | return; 101 | } 102 | 103 | if (mode.mode[0] === '+') { 104 | user.modes = user.modes || []; 105 | user.modes.push(mode.mode[1]); 106 | } else { 107 | _.pull(user.modes, mode.mode[1]); 108 | } 109 | } 110 | }); 111 | }); 112 | 113 | this.join(key); 114 | } 115 | 116 | /** 117 | * Relay messages between this channel to another 118 | * @param {IrcChannel|String} target_chan Target channel 119 | * @param {Object} opts Extra options 120 | * 121 | * opts may contain the following properties: 122 | * one_way (false) Only relay messages to target_chan, not the reverse 123 | * replay_nicks (true) Include the sending nick as part of the relayed message 124 | */ 125 | relay(target_chan, opts) { 126 | opts = _.extend({ 127 | one_way: false, 128 | replay_nicks: true 129 | }, opts); 130 | 131 | if (typeof target_chan === 'string') { 132 | target_chan = this.irc_client.channel(target_chan); 133 | } 134 | const this_stream = this.stream(opts); 135 | const other_stream = target_chan.stream(opts); 136 | 137 | this_stream.pipe(other_stream); 138 | if (!opts.one_way) { 139 | other_stream.pipe(this_stream); 140 | } 141 | } 142 | 143 | stream(stream_opts) { 144 | const read_queue = []; 145 | let is_reading = false; 146 | 147 | const stream = new DuplexStream({ 148 | objectMode: true, 149 | 150 | write: (chunk, encoding, next) => { 151 | // Support piping from one irc buffer to another 152 | if (typeof chunk === 'object' && typeof chunk.message === 'string') { 153 | if (stream_opts.replay_nicks) { 154 | chunk = '<' + chunk.nick + '> ' + chunk.message; 155 | } else { 156 | chunk = chunk.message; 157 | } 158 | } 159 | 160 | this.say(chunk.toString()); 161 | next(); 162 | }, 163 | 164 | read: () => { 165 | is_reading = true; 166 | 167 | while (read_queue.length > 0) { 168 | const message = read_queue.shift(); 169 | if (stream.push(message) === false) { 170 | is_reading = false; 171 | break; 172 | } 173 | } 174 | } 175 | }); 176 | 177 | this.irc_client.on('privmsg', (event) => { 178 | if (this.irc_client.caseCompare(event.target, this.name)) { 179 | read_queue.push(event); 180 | 181 | if (is_reading) { 182 | stream._read(); 183 | } 184 | } 185 | }); 186 | 187 | return stream; 188 | } 189 | 190 | updateUsers(cb) { 191 | const updateUserList = (event) => { 192 | if (this.irc_client.caseCompare(event.channel, this.name)) { 193 | this.irc_client.removeListener('userlist', updateUserList); 194 | if (typeof cb === 'function') { cb(this); } 195 | } 196 | }; 197 | 198 | this.irc_client.on('userlist', updateUserList); 199 | this.irc_client.raw('NAMES', this.name); 200 | } 201 | }; 202 | -------------------------------------------------------------------------------- /src/commands/command.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | clone: require('lodash/clone'), 5 | }; 6 | 7 | const numberRegex = /^[0-9.]{1,}$/; 8 | 9 | module.exports = class IrcCommand { 10 | constructor(command, data) { 11 | this.command = command += ''; 12 | this.params = _.clone(data.params); 13 | this.tags = _.clone(data.tags); 14 | 15 | this.prefix = data.prefix; 16 | this.nick = data.nick; 17 | this.ident = data.ident; 18 | this.hostname = data.hostname; 19 | } 20 | 21 | getTag(tag_name) { 22 | return this.tags[tag_name.toLowerCase()]; 23 | } 24 | 25 | getServerTime() { 26 | const timeTag = this.getTag('time'); 27 | 28 | // Explicitly return undefined if theres no time 29 | // or the value is an empty string 30 | if (!timeTag) { 31 | return undefined; 32 | } 33 | 34 | // If parsing fails for some odd reason, also fallback to 35 | // undefined, instead of returning NaN 36 | const time = Date.parse(timeTag) || undefined; 37 | 38 | // Support for znc.in/server-time unix timestamps 39 | if (!time && numberRegex.test(timeTag)) { 40 | return new Date(timeTag * 1000).getTime(); 41 | } 42 | 43 | return time; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/commands/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | reduce: require('lodash/reduce'), 5 | find: require('lodash/find'), 6 | uniq: require('lodash/uniq'), 7 | }; 8 | const EventEmitter = require('eventemitter3'); 9 | const irc_numerics = require('./numerics'); 10 | const IrcCommand = require('./command'); 11 | 12 | module.exports = class IrcCommandHandler extends EventEmitter { 13 | constructor(client) { 14 | super(); 15 | 16 | // Adds an 'all' event to .emit() 17 | this.addAllEventName(); 18 | 19 | this.client = client; 20 | this.connection = client.connection; 21 | this.network = client.network; 22 | this.handlers = []; 23 | 24 | this.request_extra_caps = []; 25 | 26 | this.resetCache(); 27 | 28 | require('./handlers/registration')(this); 29 | require('./handlers/channel')(this); 30 | require('./handlers/user')(this); 31 | require('./handlers/messaging')(this); 32 | require('./handlers/misc')(this); 33 | require('./handlers/generics')(this); 34 | } 35 | 36 | dispatch(message) { 37 | const irc_command = new IrcCommand(message.command.toUpperCase(), message); 38 | 39 | // Batched commands will be collected and executed as a transaction 40 | const batch_id = irc_command.getTag('batch'); 41 | if (batch_id) { 42 | const cache_key = 'batch.' + batch_id; 43 | if (this.hasCache(cache_key)) { 44 | const cache = this.cache(cache_key); 45 | cache.commands.push(irc_command); 46 | } else { 47 | // If we don't have this batch ID in cache, it either means that the 48 | // server hasn't sent the starting batch command or that the server 49 | // has already sent the end batch command. 50 | } 51 | } else { 52 | this.executeCommand(irc_command); 53 | } 54 | } 55 | 56 | executeCommand(irc_command) { 57 | let command_name = irc_command.command; 58 | 59 | // Check if we have a numeric->command name- mapping for this command 60 | if (irc_numerics[irc_command.command.toUpperCase()]) { 61 | command_name = irc_numerics[irc_command.command.toUpperCase()]; 62 | } 63 | 64 | if (this.handlers[command_name]) { 65 | this.handlers[command_name](irc_command, this); 66 | } else { 67 | this.emitUnknownCommand(irc_command); 68 | } 69 | } 70 | 71 | requestExtraCaps(cap) { 72 | this.request_extra_caps = _.uniq(this.request_extra_caps.concat(cap)); 73 | } 74 | 75 | addHandler(command, handler) { 76 | if (typeof handler !== 'function') { 77 | return false; 78 | } 79 | this.handlers[command] = handler; 80 | } 81 | 82 | emitUnknownCommand(command) { 83 | this.emit('unknown command', command); 84 | } 85 | 86 | // Adds an 'all' event to .emit() 87 | addAllEventName() { 88 | const original_emit = this.emit; 89 | this.emit = function() { 90 | const args = Array.prototype.slice.call(arguments, 0); 91 | original_emit.apply(this, ['all'].concat(args)); 92 | original_emit.apply(this, args); 93 | }; 94 | } 95 | 96 | /** 97 | * Convert a mode string such as '+k pass', or '-i' to a readable 98 | * format. 99 | * [ { mode: '+k', param: 'pass' } ] 100 | * [ { mode: '-i', param: null } ] 101 | */ 102 | parseModeList(mode_string, mode_params) { 103 | const chanmodes = this.network.options.CHANMODES || []; 104 | let prefixes = this.network.options.PREFIX || []; 105 | let always_param = (chanmodes[0] || '').concat((chanmodes[1] || '')); 106 | const modes = []; 107 | let i; 108 | let j; 109 | let add; 110 | 111 | if (!mode_string) { 112 | return modes; 113 | } 114 | 115 | prefixes = _.reduce(prefixes, function(list, prefix) { 116 | list.push(prefix.mode); 117 | return list; 118 | }, []); 119 | always_param = always_param.split('').concat(prefixes); 120 | 121 | const hasParam = function(mode, isAdd) { 122 | const matchMode = function(m) { 123 | return m === mode; 124 | }; 125 | 126 | if (_.find(always_param, matchMode)) { 127 | return true; 128 | } 129 | 130 | if (isAdd && _.find((chanmodes[2] || '').split(''), matchMode)) { 131 | return true; 132 | } 133 | 134 | return false; 135 | }; 136 | 137 | j = 0; 138 | for (i = 0; i < mode_string.length; i++) { 139 | switch (mode_string[i]) { 140 | case '+': 141 | add = true; 142 | break; 143 | case '-': 144 | add = false; 145 | break; 146 | default: 147 | if (hasParam(mode_string[i], add)) { 148 | modes.push({ mode: (add ? '+' : '-') + mode_string[i], param: mode_params[j] }); 149 | j++; 150 | } else { 151 | modes.push({ mode: (add ? '+' : '-') + mode_string[i], param: null }); 152 | } 153 | } 154 | } 155 | 156 | return modes; 157 | } 158 | 159 | /** 160 | * Cache object for commands buffering data before emitting them 161 | * eg. 162 | * var cache = this.cache('userlist'); 163 | * cache.nicks = []; 164 | * cache.destroy(); 165 | */ 166 | cache(id) { 167 | let cache = this._caches[id]; 168 | 169 | if (!cache) { 170 | const destroyCacheFn = (cacheToDestroy, idInCache) => { 171 | return function() { 172 | delete cacheToDestroy[idInCache]; 173 | }; 174 | }; 175 | 176 | // We don't want the destoryCache to be iterable 177 | cache = Object.defineProperty({}, 'destroy', { 178 | enumerable: false, 179 | configurable: false, 180 | value: destroyCacheFn(this._caches, id) 181 | }); 182 | this._caches[id] = cache; 183 | } 184 | 185 | return cache; 186 | } 187 | 188 | hasCache(id) { 189 | return this._caches && Object.prototype.hasOwnProperty.call(this._caches, id); 190 | } 191 | 192 | resetCache() { 193 | this._caches = Object.create(null); 194 | } 195 | }; 196 | -------------------------------------------------------------------------------- /src/commands/handlers/channel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | each: require('lodash/each'), 5 | }; 6 | const Helpers = require('../../helpers'); 7 | 8 | const handlers = { 9 | RPL_CHANNELMODEIS: function(command, handler) { 10 | const channel = command.params[1]; 11 | const raw_modes = command.params[2]; 12 | const raw_params = command.params.slice(3); 13 | const modes = handler.parseModeList(raw_modes, raw_params); 14 | 15 | handler.emit('channel info', { 16 | channel: channel, 17 | modes: modes, 18 | raw_modes: raw_modes, 19 | raw_params: raw_params, 20 | tags: command.tags 21 | }); 22 | }, 23 | 24 | RPL_CREATIONTIME: function(command, handler) { 25 | const channel = command.params[1]; 26 | 27 | handler.emit('channel info', { 28 | channel: channel, 29 | created_at: parseInt(command.params[2], 10), 30 | tags: command.tags 31 | }); 32 | }, 33 | 34 | RPL_CHANNEL_URL: function(command, handler) { 35 | const channel = command.params[1]; 36 | 37 | handler.emit('channel info', { 38 | channel: channel, 39 | url: command.params[command.params.length - 1], 40 | tags: command.tags 41 | }); 42 | }, 43 | 44 | RPL_NAMEREPLY: function(command, handler) { 45 | const members = command.params[command.params.length - 1].split(' '); 46 | const normalized_channel = handler.client.caseLower(command.params[2]); 47 | const cache = handler.cache('names.' + normalized_channel); 48 | 49 | if (!cache.members) { 50 | cache.members = []; 51 | } 52 | 53 | _.each(members, function(member) { 54 | if (!member) { 55 | return; 56 | } 57 | let j = 0; 58 | const modes = []; 59 | let user = null; 60 | 61 | // If we have prefixes, strip them from the nick and keep them separate 62 | if (handler.network.options.PREFIX) { 63 | for (j = 0; j < handler.network.options.PREFIX.length; j++) { 64 | if (member[0] === handler.network.options.PREFIX[j].symbol) { 65 | modes.push(handler.network.options.PREFIX[j].mode); 66 | member = member.substring(1); 67 | } 68 | } 69 | } 70 | 71 | // We may have a full user mask if the userhost-in-names CAP is enabled 72 | user = Helpers.parseMask(member); 73 | 74 | cache.members.push({ 75 | nick: user.nick, 76 | ident: user.user, 77 | hostname: user.host, 78 | modes: modes, 79 | tags: command.tags 80 | }); 81 | }); 82 | }, 83 | 84 | RPL_ENDOFNAMES: function(command, handler) { 85 | const normalized_channel = handler.client.caseLower(command.params[1]); 86 | const cache = handler.cache('names.' + normalized_channel); 87 | handler.emit('userlist', { 88 | channel: command.params[1], 89 | users: cache.members || [], 90 | tags: command.tags 91 | }); 92 | cache.destroy(); 93 | }, 94 | 95 | RPL_INVITELIST: function(command, handler) { 96 | const normalized_channel = handler.client.caseLower(command.params[1]); 97 | const cache = handler.cache('inviteList.' + normalized_channel); 98 | if (!cache.invites) { 99 | cache.invites = []; 100 | } 101 | 102 | cache.invites.push({ 103 | channel: command.params[1], 104 | invited: command.params[2], 105 | invited_by: command.params[3], 106 | invited_at: command.params[4], 107 | tags: command.tags 108 | }); 109 | }, 110 | 111 | RPL_ENDOFINVITELIST: function(command, handler) { 112 | const normalized_channel = handler.client.caseLower(command.params[1]); 113 | const cache = handler.cache('inviteList.' + normalized_channel); 114 | handler.emit('inviteList', { 115 | channel: command.params[1], 116 | invites: cache.invites || [], 117 | tags: command.tags 118 | }); 119 | 120 | cache.destroy(); 121 | }, 122 | 123 | RPL_BANLIST: function(command, handler) { 124 | const normalized_channel = handler.client.caseLower(command.params[1]); 125 | const cache = handler.cache('banlist.' + normalized_channel); 126 | if (!cache.bans) { 127 | cache.bans = []; 128 | } 129 | 130 | cache.bans.push({ 131 | channel: command.params[1], 132 | banned: command.params[2], 133 | banned_by: command.params[3], 134 | banned_at: command.params[4], 135 | tags: command.tags 136 | }); 137 | }, 138 | 139 | RPL_ENDOFBANLIST: function(command, handler) { 140 | const normalized_channel = handler.client.caseLower(command.params[1]); 141 | const cache = handler.cache('banlist.' + normalized_channel); 142 | handler.emit('banlist', { 143 | channel: command.params[1], 144 | bans: cache.bans || [], 145 | tags: command.tags 146 | }); 147 | 148 | cache.destroy(); 149 | }, 150 | 151 | RPL_EXCEPTLIST: function(command, handler) { 152 | const normalized_channel = handler.client.caseLower(command.params[1]); 153 | const cache = handler.cache('exceptlist.' + normalized_channel); 154 | if (!cache.excepts) { 155 | cache.excepts = []; 156 | } 157 | 158 | cache.excepts.push({ 159 | channel: command.params[1], 160 | except: command.params[2], 161 | except_by: command.params[3], 162 | except_at: command.params[4], 163 | tags: command.tags 164 | }); 165 | }, 166 | 167 | RPL_ENDOFEXCEPTLIST: function(command, handler) { 168 | const normalized_channel = handler.client.caseLower(command.params[1]); 169 | const cache = handler.cache('exceptlist.' + normalized_channel); 170 | handler.emit('exceptlist', { 171 | channel: command.params[1], 172 | excepts: cache.excepts || [], 173 | tags: command.tags 174 | }); 175 | 176 | cache.destroy(); 177 | }, 178 | 179 | RPL_TOPIC: function(command, handler) { 180 | handler.emit('topic', { 181 | channel: command.params[1], 182 | topic: command.params[command.params.length - 1], 183 | tags: command.tags, 184 | batch: command.batch 185 | }); 186 | }, 187 | 188 | RPL_NOTOPIC: function(command, handler) { 189 | handler.emit('topic', { 190 | channel: command.params[1], 191 | topic: '', 192 | tags: command.tags, 193 | batch: command.batch 194 | }); 195 | }, 196 | 197 | RPL_TOPICWHOTIME: function(command, handler) { 198 | const parsed = Helpers.parseMask(command.params[2]); 199 | handler.emit('topicsetby', { 200 | nick: parsed.nick, 201 | ident: parsed.user, 202 | hostname: parsed.host, 203 | channel: command.params[1], 204 | when: command.params[3], 205 | tags: command.tags 206 | }); 207 | }, 208 | 209 | JOIN: function(command, handler) { 210 | let channel; 211 | let gecos_idx = 1; 212 | const data = {}; 213 | 214 | if (typeof command.params[0] === 'string' && command.params[0] !== '') { 215 | channel = command.params[0]; 216 | } 217 | 218 | if (handler.network.cap.isEnabled('extended-join')) { 219 | data.account = command.params[1] === '*' ? false : command.params[1]; 220 | gecos_idx = 2; 221 | } 222 | 223 | data.nick = command.nick; 224 | data.ident = command.ident; 225 | data.hostname = command.hostname; 226 | data.gecos = command.params[gecos_idx] || ''; 227 | data.channel = channel; 228 | data.time = command.getServerTime(); 229 | data.tags = command.tags; 230 | data.batch = command.batch; 231 | handler.emit('join', data); 232 | }, 233 | 234 | PART: function(command, handler) { 235 | const time = command.getServerTime(); 236 | 237 | handler.emit('part', { 238 | nick: command.nick, 239 | ident: command.ident, 240 | hostname: command.hostname, 241 | channel: command.params[0], 242 | message: command.params[command.params.length - 1] || '', 243 | time: time, 244 | tags: command.tags, 245 | batch: command.batch 246 | }); 247 | }, 248 | 249 | KICK: function(command, handler) { 250 | const time = command.getServerTime(); 251 | 252 | handler.emit('kick', { 253 | kicked: command.params[1], 254 | nick: command.nick, 255 | ident: command.ident, 256 | hostname: command.hostname, 257 | channel: command.params[0], 258 | message: command.params[command.params.length - 1] || '', 259 | time: time, 260 | tags: command.tags, 261 | batch: command.batch 262 | }); 263 | }, 264 | 265 | QUIT: function(command, handler) { 266 | const time = command.getServerTime(); 267 | 268 | handler.emit('quit', { 269 | nick: command.nick, 270 | ident: command.ident, 271 | hostname: command.hostname, 272 | message: command.params[command.params.length - 1] || '', 273 | time: time, 274 | tags: command.tags, 275 | batch: command.batch 276 | }); 277 | }, 278 | 279 | TOPIC: function(command, handler) { 280 | // If we don't have an associated channel, no need to continue 281 | if (!command.params[0]) { 282 | return; 283 | } 284 | 285 | // Check if we have a server-time 286 | const time = command.getServerTime(); 287 | 288 | const channel = command.params[0]; 289 | const topic = command.params[command.params.length - 1] || ''; 290 | 291 | handler.emit('topic', { 292 | nick: command.nick, 293 | channel: channel, 294 | topic: topic, 295 | time: time, 296 | tags: command.tags 297 | }); 298 | }, 299 | 300 | INVITE: function(command, handler) { 301 | const time = command.getServerTime(); 302 | 303 | handler.emit('invite', { 304 | nick: command.nick, 305 | ident: command.ident, 306 | hostname: command.hostname, 307 | invited: command.params[0], 308 | channel: command.params[1], 309 | time: time, 310 | tags: command.tags 311 | }); 312 | }, 313 | 314 | RPL_INVITING: function(command, handler) { 315 | const time = command.getServerTime(); 316 | 317 | handler.emit('invited', { 318 | nick: command.params[1], 319 | channel: command.params[2], 320 | time: time, 321 | tags: command.tags 322 | }); 323 | } 324 | }; 325 | 326 | module.exports = function AddCommandHandlers(command_controller) { 327 | _.each(handlers, function(handler, handler_command) { 328 | command_controller.addHandler(handler_command, handler); 329 | }); 330 | }; 331 | -------------------------------------------------------------------------------- /src/commands/handlers/generics.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* 4 | 5 | Generic IRC events. Simply passing selected IRC params into javascript objects 6 | 7 | Example 8 | ERROR: { IRC Command to match 9 | event: 'error', Event name to trigger on the client instance 10 | reason: -1 Property on the triggered event, and which IRC param to should contain 11 | }, 12 | */ 13 | 14 | const generics = { 15 | ERROR: { 16 | event: 'irc error', 17 | error: 'irc', 18 | reason: -1 19 | }, 20 | 21 | ERR_PASSWDMISMATCH: { 22 | event: 'irc error', 23 | error: 'password_mismatch' 24 | }, 25 | 26 | ERR_LINKCHANNEL: { 27 | event: 'channel_redirect', 28 | from: 1, 29 | to: 2 30 | }, 31 | 32 | ERR_NOSUCHNICK: { 33 | event: 'irc error', 34 | error: 'no_such_nick', 35 | nick: 1, 36 | reason: -1 37 | }, 38 | 39 | ERR_NOSUCHSERVER: { 40 | event: 'irc error', 41 | error: 'no_such_server', 42 | server: 1, 43 | reason: -1 44 | }, 45 | 46 | ERR_CANNOTSENDTOCHAN: { 47 | event: 'irc error', 48 | error: 'cannot_send_to_channel', 49 | channel: 1, 50 | reason: -1 51 | }, 52 | 53 | ERR_CANNOTSENDTOUSER: { 54 | event: 'irc error', 55 | error: 'cannot_send_to_user', 56 | nick: 1, 57 | reason: -1 58 | }, 59 | 60 | ERR_TOOMANYCHANNELS: { 61 | event: 'irc error', 62 | error: 'too_many_channels', 63 | channel: 1, 64 | reason: -1 65 | }, 66 | 67 | ERR_USERNOTINCHANNEL: { 68 | event: 'irc error', 69 | error: 'user_not_in_channel', 70 | nick: 0, 71 | channel: 1, 72 | reason: -1 73 | }, 74 | 75 | ERR_NOTONCHANNEL: { 76 | event: 'irc error', 77 | error: 'not_on_channel', 78 | channel: 1, 79 | reason: -1 80 | }, 81 | 82 | ERR_USERONCHANNEL: { 83 | event: 'irc error', 84 | error: 'user_on_channel', 85 | nick: 1, 86 | channel: 2 87 | }, 88 | 89 | ERR_CHANNELISFULL: { 90 | event: 'irc error', 91 | error: 'channel_is_full', 92 | channel: 1, 93 | reason: -1 94 | }, 95 | 96 | ERR_INVITEONLYCHAN: { 97 | event: 'irc error', 98 | error: 'invite_only_channel', 99 | channel: 1, 100 | reason: -1 101 | }, 102 | 103 | ERR_BANNEDFROMCHAN: { 104 | event: 'irc error', 105 | error: 'banned_from_channel', 106 | channel: 1, 107 | reason: -1 108 | }, 109 | ERR_BADCHANNELKEY: { 110 | event: 'irc error', 111 | error: 'bad_channel_key', 112 | channel: 1, 113 | reason: -1 114 | }, 115 | 116 | ERR_CHANOPRIVSNEEDED: { 117 | event: 'irc error', 118 | error: 'chanop_privs_needed', 119 | channel: 1, 120 | reason: -1 121 | }, 122 | 123 | ERR_UNKNOWNCOMMAND: { 124 | event: 'irc error', 125 | error: 'unknown_command', 126 | command: 1, 127 | reason: -1 128 | }, 129 | 130 | ERR_YOUREBANNEDCREEP: { 131 | event: 'irc error', 132 | error: 'banned_from_network', 133 | reason: -1, 134 | }, 135 | 136 | ERR_MONLISTFULL: { 137 | event: 'irc error', 138 | error: 'monitor_list_full', 139 | reason: -1 140 | }, 141 | }; 142 | 143 | const generic_keys = Object.keys(generics); 144 | 145 | module.exports = function AddCommandHandlers(command_controller) { 146 | generic_keys.forEach(function(generic_command) { 147 | const generic = generics[generic_command]; 148 | 149 | command_controller.addHandler(generic_command, function(command, handler) { 150 | const event_obj = {}; 151 | const event_keys = Object.keys(generic); 152 | let val; 153 | 154 | for (let i = 0; i < event_keys.length; i++) { 155 | if (event_keys[i] === 'event') { 156 | continue; 157 | } 158 | 159 | val = generic[event_keys[i]]; 160 | if (typeof val === 'string') { 161 | event_obj[event_keys[i]] = val; 162 | } else if (val >= 0) { 163 | event_obj[event_keys[i]] = command.params[val]; 164 | } else if (val < 0) { 165 | event_obj[event_keys[i]] = command.params[command.params.length + val]; 166 | } 167 | } 168 | 169 | if (event_obj.channel) { 170 | // Extract the group from any errors targetted towards channels with a statusmsg prefix 171 | // Eg. @#channel 172 | const parsed = handler.network.extractTargetGroup(event_obj.channel); 173 | if (parsed) { 174 | event_obj.channel = parsed.target; 175 | event_obj.target_group = parsed.target_group; 176 | } 177 | } 178 | 179 | handler.emit(generic.event, event_obj); 180 | }); 181 | }); 182 | }; 183 | -------------------------------------------------------------------------------- /src/commands/handlers/messaging.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | each: require('lodash/each'), 5 | find: require('lodash/find'), 6 | }; 7 | const util = require('util'); 8 | 9 | const handlers = { 10 | NOTICE: function(command, handler) { 11 | const time = command.getServerTime(); 12 | const message = command.params[command.params.length - 1]; 13 | let target = command.params[0]; 14 | let target_group; 15 | 16 | if ((message.charAt(0) === '\x01') && (message.charAt(message.length - 1) === '\x01')) { 17 | // It's a CTCP response 18 | handler.emit('ctcp response', { 19 | nick: command.nick, 20 | ident: command.ident, 21 | hostname: command.hostname, 22 | target: target, 23 | type: (message.substring(1, message.length - 1).split(' ') || [null])[0], 24 | message: message.substring(1, message.length - 1), 25 | time: time, 26 | account: command.getTag('account'), 27 | tags: command.tags 28 | }); 29 | } else { 30 | const parsed_target = handler.network.extractTargetGroup(target); 31 | if (parsed_target) { 32 | target = parsed_target.target; 33 | target_group = parsed_target.target_group; 34 | } 35 | 36 | handler.emit('notice', { 37 | from_server: !command.nick, 38 | nick: command.nick, 39 | ident: command.ident, 40 | hostname: command.hostname, 41 | target: target, 42 | group: target_group, 43 | message: message, 44 | tags: command.tags, 45 | time: time, 46 | account: command.getTag('account'), 47 | batch: command.batch 48 | }); 49 | } 50 | }, 51 | 52 | PRIVMSG: function(command, handler) { 53 | const time = command.getServerTime(); 54 | const message = command.params[command.params.length - 1]; 55 | let target = command.params[0]; 56 | let target_group; 57 | 58 | const parsed_target = handler.network.extractTargetGroup(target); 59 | if (parsed_target) { 60 | target = parsed_target.target; 61 | target_group = parsed_target.target_group; 62 | } 63 | 64 | if ((message.charAt(0) === '\x01') && (message.charAt(message.length - 1) === '\x01')) { 65 | // CTCP request 66 | const ctcp_command = message.slice(1, -1).split(' ')[0].toUpperCase(); 67 | if (ctcp_command === 'ACTION') { 68 | handler.emit('action', { 69 | from_server: !command.nick, 70 | nick: command.nick, 71 | ident: command.ident, 72 | hostname: command.hostname, 73 | target: target, 74 | group: target_group, 75 | message: message.substring(8, message.length - 1), 76 | tags: command.tags, 77 | time: time, 78 | account: command.getTag('account'), 79 | batch: command.batch 80 | }); 81 | } else if (ctcp_command === 'VERSION' && handler.connection.options.version) { 82 | handler.connection.write(util.format( 83 | 'NOTICE %s :\x01VERSION %s\x01', 84 | command.nick, 85 | handler.connection.options.version 86 | )); 87 | } else { 88 | handler.emit('ctcp request', { 89 | from_server: !command.nick, 90 | nick: command.nick, 91 | ident: command.ident, 92 | hostname: command.hostname, 93 | target: target, 94 | group: target_group, 95 | type: ctcp_command || null, 96 | message: message.substring(1, message.length - 1), 97 | time: time, 98 | account: command.getTag('account'), 99 | tags: command.tags 100 | }); 101 | } 102 | } else { 103 | handler.emit('privmsg', { 104 | from_server: !command.nick, 105 | nick: command.nick, 106 | ident: command.ident, 107 | hostname: command.hostname, 108 | target: target, 109 | group: target_group, 110 | message: message, 111 | tags: command.tags, 112 | time: time, 113 | account: command.getTag('account'), 114 | batch: command.batch 115 | }); 116 | } 117 | }, 118 | TAGMSG: function(command, handler) { 119 | const time = command.getServerTime(); 120 | const target = command.params[0]; 121 | handler.emit('tagmsg', { 122 | from_server: !command.nick, 123 | nick: command.nick, 124 | ident: command.ident, 125 | hostname: command.hostname, 126 | target: target, 127 | tags: command.tags, 128 | time: time, 129 | account: command.getTag('account'), 130 | batch: command.batch 131 | }); 132 | }, 133 | 134 | RPL_WALLOPS: function(command, handler) { 135 | handler.emit('wallops', { 136 | from_server: false, 137 | nick: command.nick, 138 | ident: command.ident, 139 | hostname: command.hostname, 140 | message: command.params[command.params.length - 1], 141 | account: command.getTag('account'), 142 | tags: command.tags 143 | }); 144 | } 145 | }; 146 | 147 | module.exports = function AddCommandHandlers(command_controller) { 148 | _.each(handlers, function(handler, handler_command) { 149 | command_controller.addHandler(handler_command, handler); 150 | }); 151 | }; 152 | -------------------------------------------------------------------------------- /src/commands/handlers/misc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | each: require('lodash/each'), 5 | clone: require('lodash/clone'), 6 | map: require('lodash/map'), 7 | }; 8 | const Helpers = require('../../helpers'); 9 | 10 | const handlers = { 11 | RPL_LISTSTART: function(command, handler) { 12 | const cache = getChanListCache(handler); 13 | cache.channels = []; 14 | handler.emit('channel list start'); 15 | }, 16 | 17 | RPL_LISTEND: function(command, handler) { 18 | const cache = getChanListCache(handler); 19 | if (cache.channels.length) { 20 | handler.emit('channel list', cache.channels); 21 | cache.channels = []; 22 | } 23 | 24 | cache.destroy(); 25 | handler.emit('channel list end'); 26 | }, 27 | 28 | RPL_LIST: function(command, handler) { 29 | const cache = getChanListCache(handler); 30 | cache.channels.push({ 31 | channel: command.params[1], 32 | num_users: parseInt(command.params[2], 10), 33 | topic: command.params[3] || '', 34 | tags: command.tags 35 | }); 36 | 37 | if (cache.channels.length >= 50) { 38 | handler.emit('channel list', cache.channels); 39 | cache.channels = []; 40 | } 41 | }, 42 | 43 | RPL_MOTD: function(command, handler) { 44 | const cache = handler.cache('motd'); 45 | cache.motd += command.params[command.params.length - 1] + '\n'; 46 | }, 47 | 48 | RPL_MOTDSTART: function(command, handler) { 49 | const cache = handler.cache('motd'); 50 | cache.motd = ''; 51 | }, 52 | 53 | RPL_ENDOFMOTD: function(command, handler) { 54 | const cache = handler.cache('motd'); 55 | handler.emit('motd', { 56 | motd: cache.motd, 57 | tags: command.tags 58 | }); 59 | cache.destroy(); 60 | }, 61 | 62 | ERR_NOMOTD: function(command, handler) { 63 | const params = _.clone(command.params); 64 | params.shift(); 65 | handler.emit('motd', { 66 | error: command.params[command.params.length - 1], 67 | tags: command.tags 68 | }); 69 | }, 70 | 71 | RPL_OMOTD: function(command, handler) { 72 | const cache = handler.cache('oper motd'); 73 | cache.motd += command.params[command.params.length - 1] + '\n'; 74 | }, 75 | 76 | RPL_OMOTDSTART: function(command, handler) { 77 | const cache = handler.cache('oper motd'); 78 | cache.motd = ''; 79 | }, 80 | 81 | RPL_ENDOFOMOTD: function(command, handler) { 82 | const cache = handler.cache('oper motd'); 83 | handler.emit('motd', { 84 | motd: cache.motd, 85 | tags: command.tags 86 | }); 87 | cache.destroy(); 88 | }, 89 | 90 | ERR_NOOPERMOTD: function(command, handler) { 91 | const params = _.clone(command.params); 92 | params.shift(); 93 | handler.emit('motd', { 94 | error: command.params[command.params.length - 1], 95 | tags: command.tags 96 | }); 97 | }, 98 | 99 | RPL_WHOREPLY: function(command, handler) { 100 | const cache = handler.cache('who'); 101 | if (!cache.members) { 102 | cache.members = []; 103 | } 104 | 105 | const params = command.params; 106 | const { parsedFlags, unparsedFlags } = Helpers.parseWhoFlags(params[6], handler.network.options); 107 | 108 | let hops_away = 0; 109 | let realname = params[7]; 110 | 111 | // The realname should be in the format of " " 112 | const space_idx = realname.indexOf(' '); 113 | if (space_idx > -1) { 114 | hops_away = parseInt(realname.substr(0, space_idx), 10); 115 | realname = realname.substr(space_idx + 1); 116 | } 117 | 118 | cache.members.push({ 119 | nick: params[5], 120 | ident: params[2], 121 | hostname: params[3], 122 | server: params[4], 123 | real_name: realname, 124 | num_hops_away: hops_away, 125 | channel: params[1], 126 | tags: command.tags, 127 | unparsed_flags: unparsedFlags, 128 | ...parsedFlags, 129 | }); 130 | }, 131 | 132 | RPL_WHOSPCRPL: function(command, handler) { 133 | const cache = handler.cache('who'); 134 | if (!cache.members) { 135 | cache.members = []; 136 | cache.type = 'whox'; 137 | } 138 | 139 | const client = handler.client; 140 | const params = command.params; 141 | 142 | if (cache.token === 0) { 143 | // Token validation has already been attempted but failed, 144 | // ignore this event as it will not be emitted 145 | return; 146 | } 147 | 148 | if (!cache.token) { 149 | const token = parseInt(params[1], 10) || 0; 150 | if (token && params.length === 12 && client.whox_token.validate(token)) { 151 | // Token is valid, store it in the cache 152 | cache.token = token; 153 | } else { 154 | // This event does not have a valid token so did not come from irc-fw, 155 | // ignore it as the response order may differ 156 | cache.token = 0; 157 | return; 158 | } 159 | } 160 | 161 | const { parsedFlags, unparsedFlags } = Helpers.parseWhoFlags(params[7], handler.network.options); 162 | 163 | // Some ircd's use n/a for no level, use undefined to represent no level 164 | const op_level = /^[0-9]+$/.test(params[10]) ? parseInt(params[10], 10) : undefined; 165 | 166 | cache.members.push({ 167 | nick: params[6], 168 | ident: params[3], 169 | hostname: params[4], 170 | server: params[5], 171 | op_level: op_level, 172 | real_name: params[11], 173 | account: params[9] === '0' ? '' : params[9], 174 | num_hops_away: parseInt(params[8], 10), 175 | channel: params[2], 176 | tags: command.tags, 177 | unparsed_flags: unparsedFlags, 178 | ...parsedFlags, 179 | }); 180 | }, 181 | 182 | RPL_ENDOFWHO: function(command, handler) { 183 | const cache = handler.cache('who'); 184 | 185 | if (cache.type === 'whox' && !cache.token) { 186 | // Dont emit wholist for whox requests without a token 187 | // they did not come from irc-fw 188 | cache.destroy(); 189 | return; 190 | } 191 | 192 | handler.emit('wholist', { 193 | target: command.params[1], 194 | users: cache.members || [], 195 | tags: command.tags 196 | }); 197 | cache.destroy(); 198 | }, 199 | 200 | PING: function(command, handler) { 201 | handler.connection.write('PONG ' + command.params[command.params.length - 1]); 202 | 203 | const time = command.getServerTime(); 204 | handler.emit('ping', { 205 | message: command.params[1], 206 | time: time, 207 | tags: command.tags 208 | }); 209 | }, 210 | 211 | PONG: function(command, handler) { 212 | const time = command.getServerTime(); 213 | 214 | if (time) { 215 | handler.network.addServerTimeOffset(time); 216 | } 217 | 218 | handler.emit('pong', { 219 | message: command.params[1], 220 | time: time, 221 | tags: command.tags 222 | }); 223 | }, 224 | 225 | MODE: function(command, handler) { 226 | // Check if we have a server-time 227 | const time = command.getServerTime(); 228 | 229 | // Get a JSON representation of the modes 230 | const raw_modes = command.params[1]; 231 | const raw_params = command.params.slice(2); 232 | const modes = handler.parseModeList(raw_modes, raw_params); 233 | 234 | handler.emit('mode', { 235 | target: command.params[0], 236 | nick: command.nick || command.prefix || '', 237 | modes: modes, 238 | time: time, 239 | raw_modes: raw_modes, 240 | raw_params: raw_params, 241 | tags: command.tags, 242 | batch: command.batch 243 | }); 244 | }, 245 | 246 | RPL_LINKS: function(command, handler) { 247 | const cache = handler.cache('links'); 248 | cache.links = cache.links || []; 249 | cache.links.push({ 250 | address: command.params[1], 251 | access_via: command.params[2], 252 | hops: parseInt(command.params[3].split(' ')[0]), 253 | description: command.params[3].split(' ').splice(1).join(' '), 254 | tags: command.tags 255 | }); 256 | }, 257 | 258 | RPL_ENDOFLINKS: function(command, handler) { 259 | const cache = handler.cache('links'); 260 | handler.emit('server links', { 261 | links: cache.links 262 | }); 263 | 264 | cache.destroy(); 265 | }, 266 | 267 | RPL_INFO: function(command, handler) { 268 | const cache = handler.cache('info'); 269 | if (!cache.info) { 270 | cache.info = ''; 271 | } 272 | cache.info += command.params[command.params.length - 1] + '\n'; 273 | }, 274 | 275 | RPL_ENDOFINFO: function(command, handler) { 276 | const cache = handler.cache('info'); 277 | handler.emit('info', { 278 | info: cache.info, 279 | tags: command.tags 280 | }); 281 | cache.destroy(); 282 | }, 283 | 284 | RPL_HELPSTART: function(command, handler) { 285 | const cache = handler.cache('help'); 286 | cache.help = command.params[command.params.length - 1] + '\n'; 287 | }, 288 | 289 | RPL_HELPTXT: function(command, handler) { 290 | const cache = handler.cache('help'); 291 | cache.help += command.params[command.params.length - 1] + '\n'; 292 | }, 293 | 294 | RPL_ENDOFHELP: function(command, handler) { 295 | const cache = handler.cache('help'); 296 | handler.emit('help', { 297 | help: cache.help, 298 | tags: command.tags 299 | }); 300 | cache.destroy(); 301 | }, 302 | 303 | BATCH: function(command, handler) { 304 | const batch_start = command.params[0].substr(0, 1) === '+'; 305 | const batch_id = command.params[0].substr(1); 306 | const cache_key = 'batch.' + batch_id; 307 | 308 | if (!batch_id) { 309 | return; 310 | } 311 | 312 | if (batch_start) { 313 | const cache = handler.cache(cache_key); 314 | cache.commands = []; 315 | cache.type = command.params[1]; 316 | cache.params = command.params.slice(2); 317 | 318 | return; 319 | } 320 | 321 | if (!handler.hasCache(cache_key)) { 322 | // If we don't have this batch ID in cache, it either means that the 323 | // server hasn't sent the starting batch command or that the server 324 | // has already sent the end batch command. 325 | return; 326 | } 327 | 328 | const cache = handler.cache(cache_key); 329 | const emit_obj = { 330 | id: batch_id, 331 | type: cache.type, 332 | params: cache.params, 333 | commands: cache.commands 334 | }; 335 | 336 | // Destroy the cache object before executing each command. If one 337 | // errors out then we don't have the cache object stuck in memory. 338 | cache.destroy(); 339 | 340 | handler.emit('batch start', emit_obj); 341 | handler.emit('batch start ' + emit_obj.type, emit_obj); 342 | emit_obj.commands.forEach((c) => { 343 | c.batch = { 344 | id: batch_id, 345 | type: cache.type, 346 | params: cache.params 347 | }; 348 | handler.executeCommand(c); 349 | }); 350 | handler.emit('batch end', emit_obj); 351 | handler.emit('batch end ' + emit_obj.type, emit_obj); 352 | } 353 | }; 354 | 355 | module.exports = function AddCommandHandlers(command_controller) { 356 | _.each(handlers, function(handler, handler_command) { 357 | command_controller.addHandler(handler_command, handler); 358 | }); 359 | }; 360 | 361 | function getChanListCache(handler) { 362 | const cache = handler.cache('chanlist'); 363 | 364 | // fix some IRC networks 365 | if (!cache.channels) { 366 | cache.channels = []; 367 | } 368 | 369 | return cache; 370 | } 371 | -------------------------------------------------------------------------------- /src/commands/handlers/registration.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Helpers = require('../../helpers'); 4 | 5 | const _ = { 6 | intersection: require('lodash/intersection'), 7 | difference: require('lodash/difference'), 8 | each: require('lodash/each'), 9 | uniq: require('lodash/uniq'), 10 | }; 11 | 12 | const handlers = { 13 | RPL_WELCOME: function(command, handler) { 14 | const nick = command.params[0]; 15 | 16 | // Get the server name so we know which messages are by the server in future 17 | handler.network.server = command.prefix; 18 | 19 | handler.network.cap.negotiating = false; 20 | 21 | // We can't use the time given here as ZNC actually replays the time when it first connects 22 | // to an IRC server, not now(). Send a PING so that we can get a reliable time from PONG 23 | if (handler.network.cap.isEnabled('server-time')) { 24 | // Ping to try get a server-time in its response as soon as possible 25 | handler.connection.write('PING ' + Date.now()); 26 | } 27 | 28 | handler.emit('registered', { 29 | nick: nick, 30 | tags: command.tags 31 | }); 32 | }, 33 | 34 | RPL_YOURHOST: function(command, handler) { 35 | // Your host is ircd.network.org, running version InspIRCd-2.0 36 | const param = command.params[1] || ''; 37 | const m = param.match(/running version (.*)$/); 38 | if (!m) { 39 | handler.network.ircd = ''; 40 | } else { 41 | handler.network.ircd = m[1]; 42 | } 43 | }, 44 | 45 | RPL_ISUPPORT: function(command, handler) { 46 | const options = command.params; 47 | let i; 48 | let option; 49 | let matches; 50 | let j; 51 | 52 | for (i = 1; i < options.length; i++) { 53 | option = Helpers.splitOnce(options[i], '='); 54 | option[0] = option[0].toUpperCase(); 55 | 56 | // https://datatracker.ietf.org/doc/html/draft-brocklesby-irc-isupport-03 57 | // 2. Protocol outline [page 4] 58 | if (option[1]) { 59 | option[1] = option[1].replace(/\\x([0-9A-Fa-f]{2})/g, (match, hex) => { 60 | return String.fromCharCode(parseInt(hex, 16)); 61 | }); 62 | } 63 | 64 | handler.network.options[option[0]] = (typeof option[1] !== 'undefined') ? option[1] : true; 65 | 66 | if (option[0] === 'PREFIX') { 67 | matches = /\(([^)]*)\)(.*)/.exec(option[1]); 68 | if (matches && matches.length === 3) { 69 | handler.network.options.PREFIX = []; 70 | for (j = 0; j < matches[2].length; j++) { 71 | handler.network.options.PREFIX.push({ 72 | symbol: matches[2].charAt(j), 73 | mode: matches[1].charAt(j) 74 | }); 75 | } 76 | } else if (option[1] === '') { 77 | handler.network.options.PREFIX = []; 78 | } 79 | } else if (option[0] === 'CHANTYPES') { 80 | handler.network.options.CHANTYPES = handler.network.options.CHANTYPES.split(''); 81 | } else if (option[0] === 'STATUSMSG') { 82 | handler.network.options.STATUSMSG = handler.network.options.STATUSMSG.split(''); 83 | } else if (option[0] === 'CHANMODES') { 84 | handler.network.options.CHANMODES = option[1].split(','); 85 | } else if (option[0] === 'CASEMAPPING') { 86 | handler.network.options.CASEMAPPING = option[1]; 87 | } else if (option[0] === 'CLIENTTAGDENY') { 88 | // https://ircv3.net/specs/extensions/message-tags#rpl_isupport-tokens 89 | handler.network.options.CLIENTTAGDENY = option[1].split(',').filter((f) => !!f); 90 | } else if (option[0] === 'NETWORK') { 91 | handler.network.name = option[1]; 92 | } else if (option[0] === 'NAMESX' && !handler.network.cap.isEnabled('multi-prefix')) { 93 | // Tell the server to send us all user modes in NAMES reply, not just 94 | // the highest one 95 | handler.connection.write('PROTOCTL NAMESX'); 96 | } 97 | } 98 | 99 | handler.emit('server options', { 100 | options: handler.network.options, 101 | cap: handler.network.cap.enabled, 102 | tags: command.tags 103 | }); 104 | }, 105 | 106 | CAP: function(command, handler) { 107 | let request_caps = []; 108 | const capability_values = Object.create(null); 109 | 110 | // TODO: capability modifiers 111 | // i.e. - for disable, ~ for requires ACK, = for sticky 112 | const capabilities = command.params[command.params.length - 1] 113 | .replace(/(?:^| )[-~=]/, '') 114 | .split(' ') 115 | .filter((cap) => !!cap) 116 | .map((cap) => { 117 | // CAPs in 3.2 may be in the form of CAP=VAL. So seperate those out 118 | const sep = cap.indexOf('='); 119 | if (sep === -1) { 120 | capability_values[cap] = ''; 121 | if (command.params[1] === 'LS' || command.params[1] === 'NEW') { 122 | handler.network.cap.available.set(cap, ''); 123 | } 124 | return cap; 125 | } 126 | 127 | const cap_name = cap.substr(0, sep); 128 | const cap_value = cap.substr(sep + 1); 129 | 130 | capability_values[cap_name] = cap_value; 131 | if (command.params[1] === 'LS' || command.params[1] === 'NEW') { 132 | handler.network.cap.available.set(cap_name, cap_value); 133 | } 134 | return cap_name; 135 | }); 136 | 137 | // Which capabilities we want to enable 138 | let want = [ 139 | 'cap-notify', 140 | 'batch', 141 | 'multi-prefix', 142 | 'message-tags', 143 | 'draft/message-tags-0.2', 144 | 'away-notify', 145 | 'invite-notify', 146 | 'account-notify', 147 | 'account-tag', 148 | 'server-time', 149 | 'userhost-in-names', 150 | 'extended-join', 151 | 'znc.in/server-time-iso', 152 | 'znc.in/server-time' 153 | ]; 154 | 155 | // Optional CAPs depending on settings 156 | const saslAuth = getSaslAuth(handler); 157 | if (saslAuth || handler.connection.options.sasl_mechanism === 'EXTERNAL') { 158 | want.push('sasl'); 159 | } 160 | if (handler.connection.options.enable_chghost) { 161 | want.push('chghost'); 162 | } 163 | if (handler.connection.options.enable_setname) { 164 | want.push('setname'); 165 | } 166 | if (handler.connection.options.enable_echomessage) { 167 | want.push('echo-message'); 168 | } 169 | 170 | want = _.uniq(want.concat(handler.request_extra_caps)); 171 | 172 | switch (command.params[1]) { 173 | case 'LS': 174 | // Compute which of the available capabilities we want and request them 175 | request_caps = _.intersection(capabilities, want); 176 | if (request_caps.length > 0) { 177 | handler.network.cap.requested = handler.network.cap.requested.concat(request_caps); 178 | } 179 | 180 | // CAP 3.2 multline support. Only send our CAP requests on the last CAP LS 181 | // line which will not have * set for params[2] 182 | if (command.params[2] !== '*') { 183 | if (handler.network.cap.requested.length > 0) { 184 | handler.network.cap.negotiating = true; 185 | handler.connection.write('CAP REQ :' + handler.network.cap.requested.join(' ')); 186 | } else { 187 | handler.connection.write('CAP END'); 188 | handler.network.cap.negotiating = false; 189 | } 190 | } 191 | break; 192 | case 'ACK': 193 | if (capabilities.length > 0) { 194 | // Update list of enabled capabilities 195 | handler.network.cap.enabled = _.uniq(handler.network.cap.enabled.concat(capabilities)); 196 | 197 | // Update list of capabilities we would like to have but that aren't enabled 198 | handler.network.cap.requested = _.difference( 199 | handler.network.cap.requested, 200 | capabilities 201 | ); 202 | } 203 | if (handler.network.cap.negotiating) { 204 | let authenticating = false; 205 | if (handler.network.cap.isEnabled('sasl')) { 206 | const options_mechanism = handler.connection.options.sasl_mechanism; 207 | const wanted_mechanism = (typeof options_mechanism === 'string') ? 208 | options_mechanism.toUpperCase() : 209 | 'PLAIN'; 210 | 211 | const mechanisms = handler.network.cap.available.get('sasl'); 212 | const valid_mechanisms = mechanisms.toUpperCase().split(','); 213 | if ( 214 | !mechanisms || // SASL v3.1 215 | valid_mechanisms.includes(wanted_mechanism) // SASL v3.2 216 | ) { 217 | handler.connection.write('AUTHENTICATE ' + wanted_mechanism); 218 | authenticating = true; 219 | } else { 220 | // The client requested an SASL mechanism that is not supported by SASL v3.2 221 | // emit 'sasl failed' with reason 'unsupported_mechanism' and disconnect if requested 222 | handleSaslFail(handler, 'unsupported_mechanism'); 223 | } 224 | } else if (saslAuth || handler.connection.options.sasl_mechanism === 'EXTERNAL') { 225 | // The client provided an account for SASL auth but the server did not offer the SASL cap 226 | // emit 'sasl failed' with reason 'capability_missing' and disconnect if requested 227 | handleSaslFail(handler, 'capability_missing'); 228 | } 229 | 230 | if (!authenticating && handler.network.cap.requested.length === 0) { 231 | // If all of our requested CAPs have been handled, end CAP negotiation 232 | handler.connection.write('CAP END'); 233 | handler.network.cap.negotiating = false; 234 | } 235 | } 236 | break; 237 | case 'NAK': 238 | if (capabilities.length > 0) { 239 | handler.network.cap.requested = _.difference( 240 | handler.network.cap.requested, 241 | capabilities 242 | ); 243 | } 244 | 245 | // If all of our requested CAPs have been handled, end CAP negotiation 246 | if (handler.network.cap.negotiating && handler.network.cap.requested.length === 0) { 247 | handler.connection.write('CAP END'); 248 | handler.network.cap.negotiating = false; 249 | } 250 | break; 251 | case 'LIST': 252 | // should we do anything here? 253 | break; 254 | case 'NEW': 255 | // Request any new CAPs that we want but haven't already enabled 256 | request_caps = []; 257 | for (let i = 0; i < capabilities.length; i++) { 258 | const cap = capabilities[i]; 259 | if ( 260 | want.indexOf(cap) > -1 && 261 | request_caps.indexOf(cap) === -1 && 262 | !handler.network.cap.isEnabled(cap) 263 | ) { 264 | handler.network.cap.requested.push(cap); 265 | request_caps.push(cap); 266 | } 267 | } 268 | 269 | handler.connection.write('CAP REQ :' + request_caps.join(' ')); 270 | break; 271 | case 'DEL': 272 | // Update list of enabled capabilities 273 | handler.network.cap.enabled = _.difference( 274 | handler.network.cap.enabled, 275 | capabilities 276 | ); 277 | for (const cap_name of capabilities) { 278 | handler.network.cap.available.delete(cap_name); 279 | } 280 | break; 281 | } 282 | 283 | handler.emit('cap ' + command.params[1].toLowerCase(), { 284 | command: command.params[1], 285 | capabilities: capability_values, // for backward-compatibility 286 | }); 287 | }, 288 | 289 | AUTHENTICATE: function(command, handler) { 290 | if (command.params[0] !== '+') { 291 | if (handler.network.cap.negotiating) { 292 | handler.connection.write('CAP END'); 293 | handler.network.cap.negotiating = false; 294 | } 295 | 296 | return; 297 | } 298 | 299 | // Send blank authenticate for EXTERNAL mechanism 300 | if (handler.connection.options.sasl_mechanism === 'EXTERNAL') { 301 | handler.connection.write('AUTHENTICATE +'); 302 | return; 303 | } 304 | 305 | const saslAuth = getSaslAuth(handler); 306 | const auth_str = saslAuth.account + '\0' + 307 | saslAuth.account + '\0' + 308 | saslAuth.password; 309 | const b = Buffer.from(auth_str, 'utf8'); 310 | const b64 = b.toString('base64'); 311 | 312 | // https://ircv3.net/specs/extensions/sasl-3.1#the-authenticate-command 313 | const singleAuthCommandLength = 400; 314 | let sliceOffset = 0; 315 | 316 | while (b64.length > sliceOffset) { 317 | handler.connection.write('AUTHENTICATE ' + b64.substr(sliceOffset, singleAuthCommandLength)); 318 | sliceOffset += singleAuthCommandLength; 319 | } 320 | if (b64.length === sliceOffset) { 321 | handler.connection.write('AUTHENTICATE +'); 322 | } 323 | }, 324 | 325 | RPL_LOGGEDIN: function(command, handler) { 326 | if (handler.network.cap.negotiating === true) { 327 | handler.connection.write('CAP END'); 328 | handler.network.cap.negotiating = false; 329 | } 330 | 331 | const mask = Helpers.parseMask(command.params[1]); 332 | 333 | // Check if we have a server-time 334 | const time = command.getServerTime(); 335 | 336 | handler.emit('loggedin', { 337 | nick: command.params[0], 338 | ident: mask.user, 339 | hostname: mask.host, 340 | account: command.params[2], 341 | time: time, 342 | tags: command.tags 343 | }); 344 | 345 | handler.emit('account', { 346 | nick: command.params[0], 347 | ident: mask.user, 348 | hostname: mask.host, 349 | account: command.params[2], 350 | time: time, 351 | tags: command.tags 352 | }); 353 | }, 354 | 355 | RPL_LOGGEDOUT: function(command, handler) { 356 | const mask = Helpers.parseMask(command.params[1]); 357 | 358 | // Check if we have a server-time 359 | const time = command.getServerTime(); 360 | 361 | handler.emit('loggedout', { 362 | nick: command.params[0], 363 | ident: mask.user, 364 | hostname: mask.host, 365 | account: false, 366 | time: time, 367 | tags: command.tags 368 | }); 369 | 370 | handler.emit('account', { 371 | nick: command.params[0], 372 | ident: mask.user, 373 | hostname: mask.host, 374 | account: false, 375 | time: time, 376 | tags: command.tags 377 | }); 378 | }, 379 | 380 | RPL_SASLLOGGEDIN: function(command, handler) { 381 | if (handler.network.cap.negotiating) { 382 | handler.connection.write('CAP END'); 383 | handler.network.cap.negotiating = false; 384 | } 385 | }, 386 | 387 | ERR_NICKLOCKED: function(command, handler) { 388 | // SASL Authentication responded that the nick is locked 389 | // emit 'sasl failed' with reason 'nick_locked' and disconnect if requested 390 | handleSaslFail(handler, 'nick_locked', command); 391 | 392 | if (handler.network.cap.negotiating) { 393 | handler.connection.write('CAP END'); 394 | handler.network.cap.negotiating = false; 395 | } 396 | }, 397 | 398 | ERR_SASLFAIL: function(command, handler) { 399 | // SASL Authentication responded with failure 400 | // emit 'sasl failed' with reason 'fail' and disconnect if requested 401 | handleSaslFail(handler, 'fail', command); 402 | 403 | if (handler.network.cap.negotiating) { 404 | handler.connection.write('CAP END'); 405 | handler.network.cap.negotiating = false; 406 | } 407 | }, 408 | 409 | ERR_SASLTOOLONG: function(command, handler) { 410 | // SASL Authentication responded that the AUTHENTICATE command was too long 411 | // this should never happen as the library handles splitting 412 | // emit 'sasl failed' with reason 'too_long' and disconnect if requested 413 | handleSaslFail(handler, 'too_long', command); 414 | 415 | if (handler.network.cap.negotiating) { 416 | handler.connection.write('CAP END'); 417 | handler.network.cap.negotiating = false; 418 | } 419 | }, 420 | 421 | ERR_SASLABORTED: function(command, handler) { 422 | if (handler.network.cap.negotiating) { 423 | handler.connection.write('CAP END'); 424 | handler.network.cap.negotiating = false; 425 | } 426 | }, 427 | 428 | ERR_SASLALREADYAUTHED: function(command, handler) { 429 | // noop 430 | } 431 | }; 432 | 433 | /** 434 | * Only use the nick+password combo if an account has not been specifically given. 435 | * If an account:{account,password} has been given, use it for SASL auth. 436 | */ 437 | function getSaslAuth(handler) { 438 | const options = handler.connection.options; 439 | if (options.account && options.account.account) { 440 | // An account username has been given, use it for SASL auth 441 | return { 442 | account: options.account.account, 443 | password: options.account.password || '', 444 | }; 445 | } else if (options.account) { 446 | // An account object existed but without auth credentials 447 | return null; 448 | } else if (options.password) { 449 | // No account credentials found but we have a server password. Also use it for SASL 450 | // for ease of use 451 | return { 452 | account: options.nick, 453 | password: options.password, 454 | }; 455 | } 456 | 457 | return null; 458 | } 459 | 460 | function handleSaslFail(handler, reason, command) { 461 | const event = { 462 | reason, 463 | }; 464 | 465 | if (command) { 466 | const time = command.getServerTime(); 467 | 468 | event.message = command.params[command.params.length - 1]; 469 | event.nick = command.params[0]; 470 | event.time = time; 471 | event.tags = command.tags; 472 | } 473 | 474 | handler.emit('sasl failed', event); 475 | 476 | const sasl_disconnect_on_fail = handler.connection.options.sasl_disconnect_on_fail; 477 | if (sasl_disconnect_on_fail && handler.network.cap.negotiating) { 478 | handler.connection.end(); 479 | } 480 | } 481 | 482 | module.exports = function AddCommandHandlers(command_controller) { 483 | _.each(handlers, function(handler, handler_command) { 484 | command_controller.addHandler(handler_command, handler); 485 | }); 486 | }; 487 | -------------------------------------------------------------------------------- /src/commands/handlers/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | each: require('lodash/each'), 5 | map: require('lodash/map'), 6 | }; 7 | const Helpers = require('../../helpers'); 8 | 9 | const handlers = { 10 | NICK: function(command, handler) { 11 | // Check if we have a server-time 12 | const time = command.getServerTime(); 13 | 14 | handler.emit('nick', { 15 | nick: command.nick, 16 | ident: command.ident, 17 | hostname: command.hostname, 18 | new_nick: command.params[0], 19 | time: time, 20 | tags: command.tags, 21 | batch: command.batch 22 | }); 23 | }, 24 | 25 | ACCOUNT: function(command, handler) { 26 | // Check if we have a server-time 27 | const time = command.getServerTime(); 28 | 29 | const account = command.params[0] === '*' ? 30 | false : 31 | command.params[0]; 32 | 33 | handler.emit('account', { 34 | nick: command.nick, 35 | ident: command.ident, 36 | hostname: command.hostname, 37 | account: account, 38 | time: time, 39 | tags: command.tags 40 | }); 41 | }, 42 | 43 | // If the chghost CAP is enabled and 'enable_chghost' option is true 44 | CHGHOST: function(command, handler) { 45 | // Check if we have a server-time 46 | const time = command.getServerTime(); 47 | 48 | handler.emit('user updated', { 49 | nick: command.nick, 50 | ident: command.ident, 51 | hostname: command.hostname, 52 | new_ident: command.params[0], 53 | new_hostname: command.params[1], 54 | time: time, 55 | tags: command.tags, 56 | batch: command.batch 57 | }); 58 | }, 59 | 60 | SETNAME: function(command, handler) { 61 | // Check if we have a server-time 62 | const time = command.getServerTime(); 63 | 64 | handler.emit('user updated', { 65 | nick: command.nick, 66 | ident: command.ident, 67 | hostname: command.hostname, 68 | new_gecos: command.params[0], 69 | time: time, 70 | tags: command.tags, 71 | batch: command.batch 72 | }); 73 | }, 74 | 75 | AWAY: function(command, handler) { 76 | // Check if we have a server-time 77 | const time = command.getServerTime(); 78 | const message = command.params[command.params.length - 1] || ''; 79 | if (message === '') { // back 80 | handler.emit('back', { 81 | self: false, 82 | nick: command.nick, 83 | message: '', 84 | time: time, 85 | tags: command.tags 86 | }); 87 | } else { 88 | handler.emit('away', { 89 | self: false, 90 | nick: command.nick, 91 | message: message, 92 | time: time, 93 | tags: command.tags 94 | }); 95 | } 96 | }, 97 | 98 | RPL_NOWAWAY: function(command, handler) { 99 | // Check if we have a server-time 100 | const time = command.getServerTime(); 101 | 102 | handler.emit('away', { 103 | self: true, 104 | nick: command.params[0], 105 | message: command.params[1] || '', 106 | time: time, 107 | tags: command.tags 108 | }); 109 | }, 110 | 111 | RPL_UNAWAY: function(command, handler) { 112 | // Check if we have a server-time 113 | const time = command.getServerTime(); 114 | 115 | handler.emit('back', { 116 | self: true, 117 | nick: command.params[0], 118 | message: command.params[1] || '', // example: " is now back." 119 | time: time, 120 | tags: command.tags 121 | }); 122 | }, 123 | 124 | RPL_ISON: function(command, handler) { 125 | handler.emit('users online', { 126 | nicks: (command.params[command.params.length - 1] || '').split(' '), 127 | tags: command.tags 128 | }); 129 | }, 130 | 131 | ERR_NICKNAMEINUSE: function(command, handler) { 132 | handler.emit('nick in use', { 133 | nick: command.params[1], 134 | reason: command.params[command.params.length - 1], 135 | tags: command.tags 136 | }); 137 | }, 138 | 139 | ERR_ERRONEOUSNICKNAME: function(command, handler) { 140 | handler.emit('nick invalid', { 141 | nick: command.params[1], 142 | reason: command.params[command.params.length - 1], 143 | tags: command.tags 144 | }); 145 | }, 146 | 147 | RPL_ENDOFWHOIS: function(command, handler) { 148 | const cache_key = command.params[1].toLowerCase(); 149 | const cache = handler.cache('whois.' + cache_key); 150 | 151 | if (!cache.nick) { 152 | cache.nick = command.params[1]; 153 | cache.error = 'not_found'; 154 | } 155 | 156 | handler.emit('whois', cache); 157 | cache.destroy(); 158 | }, 159 | 160 | RPL_AWAY: function(command, handler) { 161 | const cache_key = 'whois.' + command.params[1].toLowerCase(); 162 | const message = command.params[command.params.length - 1] || 'is away'; 163 | 164 | // RPL_AWAY may come as a response to PRIVMSG, and not be a part of whois 165 | // If so, emit away event separately for it 166 | if (!handler.hasCache(cache_key)) { 167 | // Check if we have a server-time 168 | const time = command.getServerTime(); 169 | 170 | handler.emit('away', { 171 | self: false, 172 | nick: command.params[1], 173 | message: message, 174 | time: time, 175 | tags: command.tags 176 | }); 177 | 178 | return; 179 | } 180 | 181 | const cache = handler.cache(cache_key); 182 | cache.away = message; 183 | }, 184 | 185 | RPL_WHOISUSER: function(command, handler) { 186 | const cache_key = command.params[1].toLowerCase(); 187 | const cache = handler.cache('whois.' + cache_key); 188 | cache.nick = command.params[1]; 189 | cache.ident = command.params[2]; 190 | cache.hostname = command.params[3]; 191 | cache.real_name = command.params[5]; 192 | }, 193 | 194 | RPL_WHOISHELPOP: function(command, handler) { 195 | const cache_key = command.params[1].toLowerCase(); 196 | const cache = handler.cache('whois.' + cache_key); 197 | cache.helpop = command.params[command.params.length - 1]; 198 | }, 199 | 200 | RPL_WHOISBOT: function(command, handler) { 201 | const cache_key = command.params[1].toLowerCase(); 202 | const cache = handler.cache('whois.' + cache_key); 203 | cache.bot = command.params[command.params.length - 1]; 204 | }, 205 | 206 | RPL_WHOISSERVER: function(command, handler) { 207 | const cache_key = command.params[1].toLowerCase(); 208 | const cache = handler.cache('whois.' + cache_key); 209 | cache.server = command.params[2]; 210 | cache.server_info = command.params[command.params.length - 1]; 211 | }, 212 | 213 | RPL_WHOISOPERATOR: function(command, handler) { 214 | const cache_key = command.params[1].toLowerCase(); 215 | const cache = handler.cache('whois.' + cache_key); 216 | cache.operator = command.params[command.params.length - 1]; 217 | }, 218 | 219 | RPL_WHOISCHANNELS: function(command, handler) { 220 | const cache_key = command.params[1].toLowerCase(); 221 | const cache = handler.cache('whois.' + cache_key); 222 | if (cache.channels) { 223 | cache.channels += ' ' + command.params[command.params.length - 1]; 224 | } else { 225 | cache.channels = command.params[command.params.length - 1]; 226 | } 227 | }, 228 | 229 | RPL_WHOISMODES: function(command, handler) { 230 | const cache_key = command.params[1].toLowerCase(); 231 | const cache = handler.cache('whois.' + cache_key); 232 | cache.modes = command.params[command.params.length - 1]; 233 | }, 234 | 235 | RPL_WHOISIDLE: function(command, handler) { 236 | const cache_key = command.params[1].toLowerCase(); 237 | const cache = handler.cache('whois.' + cache_key); 238 | cache.idle = command.params[2]; 239 | if (command.params[3]) { 240 | cache.logon = command.params[3]; 241 | } 242 | }, 243 | 244 | RPL_WHOISREGNICK: function(command, handler) { 245 | const cache_key = command.params[1].toLowerCase(); 246 | const cache = handler.cache('whois.' + cache_key); 247 | cache.registered_nick = command.params[command.params.length - 1]; 248 | }, 249 | 250 | RPL_WHOISHOST: function(command, handler) { 251 | const cache_key = command.params[1].toLowerCase(); 252 | const cache = handler.cache('whois.' + cache_key); 253 | 254 | const last_param = command.params[command.params.length - 1]; 255 | // 378 :is connecting from @ 256 | const match = last_param.match(/.*@([^ ]+) ([^ ]+).*$/); // https://regex101.com/r/AQz7RE/2 257 | 258 | if (!match) { 259 | return; 260 | } 261 | 262 | cache.actual_ip = match[2]; 263 | cache.actual_hostname = match[1]; 264 | }, 265 | 266 | RPL_WHOISSECURE: function(command, handler) { 267 | const cache_key = command.params[1].toLowerCase(); 268 | const cache = handler.cache('whois.' + cache_key); 269 | cache.secure = true; 270 | }, 271 | 272 | RPL_WHOISCERTFP: function(command, handler) { 273 | const cache_key = command.params[1].toLowerCase(); 274 | const cache = handler.cache('whois.' + cache_key); 275 | const certfp = command.params[command.params.length - 1]; 276 | cache.certfp = cache.certfp || certfp; 277 | cache.certfps = cache.certfps || []; 278 | cache.certfps.push(certfp); 279 | }, 280 | 281 | RPL_WHOISACCOUNT: function(command, handler) { 282 | const cache_key = command.params[1].toLowerCase(); 283 | const cache = handler.cache('whois.' + cache_key); 284 | cache.account = command.params[2]; 285 | }, 286 | 287 | RPL_WHOISSPECIAL: function(command, handler) { 288 | const cache_key = command.params[1].toLowerCase(); 289 | const cache = handler.cache('whois.' + cache_key); 290 | cache.special = cache.special || []; 291 | cache.special.push(command.params[command.params.length - 1]); 292 | }, 293 | 294 | RPL_WHOISCOUNTRY: function(command, handler) { 295 | const cache_key = command.params[1].toLowerCase(); 296 | const cache = handler.cache('whois.' + cache_key); 297 | cache.country = command.params[command.params.length - 1]; 298 | if (command.params.length === 4) { 299 | cache.country_code = command.params[2]; 300 | } 301 | }, 302 | 303 | RPL_WHOISASN: function(command, handler) { 304 | const cache_key = command.params[1].toLowerCase(); 305 | const cache = handler.cache('whois.' + cache_key); 306 | cache.asn = command.params[command.params.length - 1]; 307 | }, 308 | 309 | RPL_WHOISACTUALLY: function(command, handler) { 310 | const cache_key = command.params[1].toLowerCase(); 311 | const cache = handler.cache('whois.' + cache_key); 312 | 313 | // 338 [@] :Actual user@host, Actual IP 314 | const user_host = command.params[command.params.length - 3] || ''; 315 | const mask_sep = user_host.indexOf('@'); 316 | const user = user_host.substring(0, mask_sep) || undefined; 317 | const host = user_host.substring(mask_sep + 1); 318 | const ip = command.params[command.params.length - 2]; 319 | 320 | // UnrealIRCd uses this numeric for something else resulting in ip+host 321 | // to be empty, so ignore this is that's the case 322 | if (ip && host) { 323 | cache.actual_ip = ip; 324 | cache.actual_username = user; 325 | cache.actual_hostname = host; 326 | } 327 | }, 328 | 329 | RPL_WHOWASUSER: function(command, handler) { 330 | const cache_key = command.params[1].toLowerCase(); 331 | let whois_cache = handler.cache('whois.' + cache_key); 332 | 333 | // multiple RPL_WHOWASUSER replies are received prior to the RPL_ENDOFWHOWAS command 334 | // one for each timestamp the server is aware of, from newest to oldest. 335 | // They are optionally interleaved with various other numerics such as RPL_WHOISACTUALLY etc. 336 | // Hence if we already find something we are receiving older data and need to make sure that we 337 | // store anything already in the cache into its own entry 338 | const whowas_cache = handler.cache('whowas.' + cache_key); 339 | if (!whowas_cache.whowas) { 340 | // this will get populated by the next RPL_WHOWASUSER or RPL_ENDOFWHOWAS 341 | whowas_cache.whowas = []; 342 | } else { 343 | // push the previous event prior to modifying anything 344 | whowas_cache.whowas.push(whois_cache); 345 | // ensure we are starting with a clean cache for the next data 346 | whois_cache.destroy(); 347 | whois_cache = handler.cache('whois.' + cache_key); 348 | } 349 | 350 | whois_cache.nick = command.params[1]; 351 | whois_cache.ident = command.params[2]; 352 | whois_cache.hostname = command.params[3]; 353 | whois_cache.real_name = command.params[command.params.length - 1]; 354 | }, 355 | 356 | RPL_ENDOFWHOWAS: function(command, handler) { 357 | // Because the WHOIS and WHOWAS numerics clash with eachother, 358 | // a cache key will have more than what is just in RPL_WHOWASUSER. 359 | // This is why we borrow from the whois.* cache key ID. 360 | // 361 | // This exposes some fields (that may or may not be set). 362 | // Valid keys that should always be set: nick, ident, hostname, real_name 363 | // Valid optional keys: actual_ip, actual_hostname, account, server, 364 | // server_info, actual_username 365 | // More optional fields MAY exist, depending on the type of ircd. 366 | const cache_key = command.params[1].toLowerCase(); 367 | const whois_cache = handler.cache('whois.' + cache_key); 368 | const whowas_cache = handler.cache('whowas.' + cache_key); 369 | 370 | // after all prior RPL_WHOWASUSER pushed newer events onto the history stack 371 | // push the last one to complete the set (server returns from newest to oldest) 372 | whowas_cache.whowas = whowas_cache.whowas || []; 373 | if (!whois_cache.error) { 374 | whowas_cache.whowas.push(whois_cache); 375 | Object.assign(whowas_cache, whowas_cache.whowas[0]); 376 | } else { 377 | Object.assign(whowas_cache, whois_cache); 378 | } 379 | 380 | handler.emit('whowas', whowas_cache); 381 | whois_cache.destroy(); 382 | whowas_cache.destroy(); 383 | }, 384 | 385 | ERR_WASNOSUCHNICK: function(command, handler) { 386 | const cache_key = command.params[1].toLowerCase(); 387 | const cache = handler.cache('whois.' + cache_key); 388 | 389 | cache.nick = command.params[1]; 390 | cache.error = 'no_such_nick'; 391 | }, 392 | 393 | RPL_UMODEIS: function(command, handler) { 394 | const nick = command.params[0]; 395 | const raw_modes = command.params[1]; 396 | handler.emit('user info', { 397 | nick: nick, 398 | raw_modes: raw_modes, 399 | tags: command.tags 400 | }); 401 | }, 402 | 403 | RPL_HOSTCLOAKING: function(command, handler) { 404 | handler.emit('displayed host', { 405 | nick: command.params[0], 406 | hostname: command.params[1], 407 | tags: command.tags 408 | }); 409 | }, 410 | 411 | RPL_MONONLINE: function(command, handler) { 412 | const users = (command.params[command.params.length - 1] || '').split(','); 413 | const parsed = _.map(users, user => Helpers.parseMask(user).nick); 414 | 415 | handler.emit('users online', { 416 | nicks: parsed, 417 | tags: command.tags 418 | }); 419 | }, 420 | 421 | RPL_MONOFFLINE: function(command, handler) { 422 | const users = (command.params[command.params.length - 1] || '').split(','); 423 | 424 | handler.emit('users offline', { 425 | nicks: users, 426 | tags: command.tags 427 | }); 428 | }, 429 | 430 | RPL_MONLIST: function(command, handler) { 431 | const cache = handler.cache('monitorList.' + command.params[0]); 432 | if (!cache.nicks) { 433 | cache.nicks = []; 434 | } 435 | 436 | const users = command.params[command.params.length - 1].split(','); 437 | 438 | cache.nicks.push(...users); 439 | }, 440 | 441 | RPL_ENDOFMONLIST: function(command, handler) { 442 | const cache = handler.cache('monitorList.' + command.params[0]); 443 | handler.emit('monitorList', { 444 | nicks: cache.nicks || [] 445 | }); 446 | 447 | cache.destroy(); 448 | } 449 | }; 450 | 451 | module.exports = function AddCommandHandlers(command_controller) { 452 | _.each(handlers, function(handler, handler_command) { 453 | command_controller.addHandler(handler_command, handler); 454 | }); 455 | }; 456 | -------------------------------------------------------------------------------- /src/commands/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.Command = require('./command'); 4 | module.exports.CommandHandler = require('./handler'); 5 | -------------------------------------------------------------------------------- /src/commands/numerics.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* eslint-disable quote-props */ 4 | module.exports = { 5 | '001': 'RPL_WELCOME', 6 | '002': 'RPL_YOURHOST', 7 | '003': 'RPL_CREATED', 8 | '004': 'RPL_MYINFO', 9 | '005': 'RPL_ISUPPORT', 10 | '006': 'RPL_MAPMORE', 11 | '007': 'RPL_MAPEND', 12 | '008': 'RPL_SNOMASK', 13 | '015': 'RPL_MAP', 14 | '017': 'RPL_MAPEND', 15 | '200': 'RPL_TRACELINK', 16 | '201': 'RPL_TRACECONNECTING', 17 | '202': 'RPL_TRACEHANDSHAKE', 18 | '203': 'RPL_TRACEUNKNOWN', 19 | '204': 'RPL_TRACEOPERATOR', 20 | '205': 'RPL_TRACEUSER', 21 | '206': 'RPL_TRACESERVER', 22 | '207': 'RPL_TRACESERVICE', 23 | '208': 'RPL_TRACENEWTYPE', 24 | '209': 'RPL_TRACECLASS', 25 | '210': 'RPL_TRACERECONNECT', 26 | '211': 'RPL_STATSLINKINFO', 27 | '212': 'RPL_STATSCOMMANDS', 28 | '213': 'RPL_STATSCLINE', 29 | '214': 'RPL_STATSNLINE', 30 | '215': 'RPL_STATSILINE', 31 | '216': 'RPL_STATSKLINE', 32 | '217': 'RPL_STATSPLINE', 33 | '218': 'RPL_STATSYLINE', 34 | '219': 'RPL_ENDOFSTATS', 35 | '220': 'RPL_STATSBLINE', 36 | '221': 'RPL_UMODEIS', 37 | '222': 'RPL_SQLINE_NICK', 38 | '223': 'RPL_STATS_E', 39 | '224': 'RPL_STATS_D', 40 | '229': 'RPL_SPAMFILTER', 41 | '231': 'RPL_SERVICEINFO', 42 | '232': 'RPL_ENDOFSERVICES', 43 | '233': 'RPL_SERVICE', 44 | '234': 'RPL_SERVLIST', 45 | '235': 'RPL_SERVLISTEND', 46 | '241': 'RPL_STATSLLINE', 47 | '242': 'RPL_STATSUPTIME', 48 | '243': 'RPL_STATSOLINE', 49 | '244': 'RPL_STATSHLINE', 50 | '245': 'RPL_STATSSLINE', 51 | '246': 'RPL_STATSGLINE', 52 | '247': 'RPL_STATSXLINE', 53 | '248': 'RPL_STATSULINE', 54 | '249': 'RPL_STATSDEBUG', 55 | '250': 'RPL_STATSCONN', 56 | '251': 'RPL_LUSERCLIENT', 57 | '252': 'RPL_LUSEROP', 58 | '253': 'RPL_LUSERUNKNOWN', 59 | '254': 'RPL_LUSERCHANNELS', 60 | '255': 'RPL_LUSERME', 61 | '256': 'RPL_ADMINME', 62 | '257': 'RPL_ADMINLOC1', 63 | '258': 'RPL_ADMINLOC2', 64 | '259': 'RPL_ADMINEMAIL', 65 | '265': 'RPL_LOCALUSERS', 66 | '266': 'RPL_GLOBALUSERS', 67 | '276': 'RPL_WHOISCERTFP', 68 | '290': 'RPL_HELPHDR', 69 | '291': 'RPL_HELPOP', 70 | '292': 'RPL_HELPTLR', 71 | '301': 'RPL_AWAY', 72 | '303': 'RPL_ISON', 73 | '304': 'RPL_ZIPSTATS', 74 | '305': 'RPL_UNAWAY', 75 | '306': 'RPL_NOWAWAY', 76 | '307': 'RPL_WHOISREGNICK', 77 | '310': 'RPL_WHOISHELPOP', 78 | '311': 'RPL_WHOISUSER', 79 | '312': 'RPL_WHOISSERVER', 80 | '313': 'RPL_WHOISOPERATOR', 81 | '314': 'RPL_WHOWASUSER', 82 | '315': 'RPL_ENDOFWHO', 83 | '317': 'RPL_WHOISIDLE', 84 | '318': 'RPL_ENDOFWHOIS', 85 | '319': 'RPL_WHOISCHANNELS', 86 | '320': 'RPL_WHOISSPECIAL', 87 | '321': 'RPL_LISTSTART', 88 | '322': 'RPL_LIST', 89 | '323': 'RPL_LISTEND', 90 | '324': 'RPL_CHANNELMODEIS', 91 | '328': 'RPL_CHANNEL_URL', 92 | '329': 'RPL_CREATIONTIME', 93 | '330': 'RPL_WHOISACCOUNT', 94 | '331': 'RPL_NOTOPIC', 95 | '332': 'RPL_TOPIC', 96 | '333': 'RPL_TOPICWHOTIME', 97 | '335': 'RPL_WHOISBOT', 98 | '338': 'RPL_WHOISACTUALLY', 99 | '341': 'RPL_INVITING', 100 | '344': 'RPL_WHOISCOUNTRY', 101 | '346': 'RPL_INVITELIST', 102 | '347': 'RPL_ENDOFINVITELIST', 103 | '348': 'RPL_EXCEPTLIST', 104 | '349': 'RPL_ENDOFEXCEPTLIST', 105 | '352': 'RPL_WHOREPLY', 106 | '353': 'RPL_NAMEREPLY', 107 | '354': 'RPL_WHOSPCRPL', 108 | '364': 'RPL_LINKS', 109 | '365': 'RPL_ENDOFLINKS', 110 | '366': 'RPL_ENDOFNAMES', 111 | '367': 'RPL_BANLIST', 112 | '368': 'RPL_ENDOFBANLIST', 113 | '369': 'RPL_ENDOFWHOWAS', 114 | '371': 'RPL_INFO', 115 | '372': 'RPL_MOTD', 116 | '374': 'RPL_ENDOFINFO', 117 | '375': 'RPL_MOTDSTART', 118 | '376': 'RPL_ENDOFMOTD', 119 | '378': 'RPL_WHOISHOST', 120 | '379': 'RPL_WHOISMODES', 121 | '381': 'RPL_NOWOPER', 122 | '396': 'RPL_HOSTCLOAKING', 123 | '401': 'ERR_NOSUCHNICK', 124 | '402': 'ERR_NOSUCHSERVER', 125 | '404': 'ERR_CANNOTSENDTOCHAN', 126 | '405': 'ERR_TOOMANYCHANNELS', 127 | '406': 'ERR_WASNOSUCHNICK', 128 | '421': 'ERR_UNKNOWNCOMMAND', 129 | '422': 'ERR_NOMOTD', 130 | '423': 'ERR_NOADMININFO', 131 | '425': 'ERR_NOOPERMOTD', 132 | '432': 'ERR_ERRONEOUSNICKNAME', 133 | '433': 'ERR_NICKNAMEINUSE', 134 | '441': 'ERR_USERNOTINCHANNEL', 135 | '442': 'ERR_NOTONCHANNEL', 136 | '443': 'ERR_USERONCHANNEL', 137 | '451': 'ERR_NOTREGISTERED', 138 | '461': 'ERR_NOTENOUGHPARAMS', 139 | '465': 'ERR_YOUREBANNEDCREEP', 140 | '464': 'ERR_PASSWDMISMATCH', 141 | '470': 'ERR_LINKCHANNEL', 142 | '471': 'ERR_CHANNELISFULL', 143 | '472': 'ERR_UNKNOWNMODE', 144 | '473': 'ERR_INVITEONLYCHAN', 145 | '474': 'ERR_BANNEDFROMCHAN', 146 | '475': 'ERR_BADCHANNELKEY', 147 | '481': 'ERR_NOPRIVILEGES', 148 | '482': 'ERR_CHANOPRIVSNEEDED', 149 | '483': 'ERR_CANTKILLSERVER', 150 | '484': 'ERR_ISCHANSERVICE', 151 | '485': 'ERR_ISREALSERVICE', 152 | '491': 'ERR_NOOPERHOST', 153 | '531': 'ERR_CANNOTSENDTOUSER', 154 | /* InspIRCD specific https://github.com/inspircd/inspircd-contrib/blob/master/3.0/m_asn.cpp */ 155 | '569': 'RPL_WHOISASN', 156 | '670': 'RPL_STARTTLS', 157 | '671': 'RPL_WHOISSECURE', 158 | '704': 'RPL_HELPSTART', 159 | '705': 'RPL_HELPTXT', 160 | '706': 'RPL_ENDOFHELP', 161 | '720': 'RPL_OMOTDSTART', 162 | '721': 'RPL_OMOTD', 163 | '722': 'RPL_ENDOFOMOTD', 164 | '730': 'RPL_MONONLINE', 165 | '731': 'RPL_MONOFFLINE', 166 | '732': 'RPL_MONLIST', 167 | '733': 'RPL_ENDOFMONLIST', 168 | '734': 'ERR_MONLISTFULL', 169 | '900': 'RPL_LOGGEDIN', 170 | '901': 'RPL_LOGGEDOUT', 171 | '902': 'ERR_NICKLOCKED', 172 | '903': 'RPL_SASLLOGGEDIN', 173 | '904': 'ERR_SASLFAIL', 174 | '905': 'ERR_SASLTOOLONG', 175 | '906': 'ERR_SASLABORTED', 176 | '907': 'ERR_SASLALREADYAUTHED', 177 | '972': 'ERR_CANNOTDOCOMMAND', 178 | 'WALLOPS': 'RPL_WALLOPS' 179 | }; 180 | -------------------------------------------------------------------------------- /src/connection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | pull: require('lodash/pull'), 5 | }; 6 | const EventEmitter = require('eventemitter3'); 7 | const ircLineParser = require('./irclineparser'); 8 | 9 | module.exports = class Connection extends EventEmitter { 10 | constructor(options) { 11 | super(); 12 | 13 | this.options = options || {}; 14 | 15 | this.connected = false; 16 | this.requested_disconnect = false; 17 | 18 | this.reconnect_attempts = 0; 19 | 20 | // When an IRC connection was successfully registered. 21 | this.registered = false; 22 | 23 | this.transport = null; 24 | 25 | this._timers = []; 26 | } 27 | 28 | debugOut(out) { 29 | this.emit('debug', out); 30 | } 31 | 32 | registeredSuccessfully() { 33 | this.registered = Date.now(); 34 | } 35 | 36 | connect(options) { 37 | const that = this; 38 | 39 | if (options) { 40 | this.options = options; 41 | } 42 | options = this.options; 43 | 44 | this.auto_reconnect = options.auto_reconnect || false; 45 | this.auto_reconnect_max_retries = options.auto_reconnect_max_retries || 3; 46 | this.auto_reconnect_max_wait = options.auto_reconnect_max_wait || 300000; 47 | 48 | if (this.transport) { 49 | this.clearTimers(); 50 | this.transport.removeAllListeners(); 51 | this.transport.disposeSocket(); 52 | } 53 | this.transport = new options.transport(options); 54 | 55 | if (!options.encoding || !this.setEncoding(options.encoding)) { 56 | this.setEncoding('utf8'); 57 | } 58 | 59 | bindTransportEvents(this.transport); 60 | 61 | this.registered = false; 62 | this.requested_disconnect = false; 63 | this.emit('connecting'); 64 | this.transport.connect(); 65 | 66 | function bindTransportEvents(transport) { 67 | transport.on('open', socketOpen); 68 | transport.on('line', socketLine); 69 | transport.on('close', socketClose); 70 | transport.on('debug', transportDebug); 71 | transport.on('extra', transportExtra); 72 | } 73 | 74 | function transportDebug(out) { 75 | that.debugOut(out); 76 | } 77 | 78 | function transportExtra() { 79 | // Some transports may emit extra events 80 | that.emit.apply(that, arguments); 81 | } 82 | 83 | // Called when the socket is connected and ready to start sending/receiving data. 84 | function socketOpen() { 85 | that.debugOut('Socket fully connected'); 86 | that.reconnect_attempts = 0; 87 | that.connected = true; 88 | that.emit('socket connected'); 89 | } 90 | 91 | function socketLine(line) { 92 | that.addReadBuffer(line); 93 | } 94 | 95 | function socketClose(err) { 96 | const was_connected = that.connected; 97 | let should_reconnect = false; 98 | let safely_registered = false; 99 | const registered_ms_ago = Date.now() - that.registered; 100 | 101 | // Some networks use aKills which kill a user after succesfully 102 | // registering instead of a ban, so we must wait some time after 103 | // being registered to be sure that we are connected properly. 104 | safely_registered = that.registered !== false && registered_ms_ago > 5000; 105 | 106 | that.debugOut('Socket closed. was_connected=' + was_connected + ' safely_registered=' + safely_registered + ' requested_disconnect=' + that.requested_disconnect); 107 | 108 | that.connected = false; 109 | that.clearTimers(); 110 | 111 | that.emit('socket close', err); 112 | 113 | if (that.requested_disconnect || !that.auto_reconnect) { 114 | should_reconnect = false; 115 | 116 | // If trying to reconnect, continue with it 117 | } else if (that.reconnect_attempts && that.reconnect_attempts < that.auto_reconnect_max_retries) { 118 | should_reconnect = true; 119 | 120 | // If we were originally connected OK, reconnect 121 | } else if (was_connected && safely_registered) { 122 | should_reconnect = true; 123 | } else { 124 | should_reconnect = false; 125 | } 126 | 127 | if (should_reconnect) { 128 | const reconnect_wait = that.calculateExponentialBackoff(); 129 | 130 | that.reconnect_attempts++; 131 | that.emit('reconnecting', { 132 | attempt: that.reconnect_attempts, 133 | max_retries: that.auto_reconnect_max_retries, 134 | wait: reconnect_wait 135 | }); 136 | 137 | that.debugOut('Scheduling reconnect. Attempt: ' + that.reconnect_attempts + '/' + that.auto_reconnect_max_retries + ' Wait: ' + reconnect_wait + 'ms'); 138 | that.setTimeout(() => that.connect(), reconnect_wait); 139 | } else { 140 | that.transport.removeAllListeners(); 141 | that.emit('close', !!err); 142 | that.reconnect_attempts = 0; 143 | } 144 | } 145 | } 146 | 147 | calculateExponentialBackoff() { 148 | const jitter = 1000 + Math.floor(Math.random() * 5000); 149 | const attempts = Math.min(this.reconnect_attempts, 30); 150 | const time = 1000 * Math.pow(2, attempts); 151 | return Math.min(time, this.auto_reconnect_max_wait) + jitter; 152 | } 153 | 154 | addReadBuffer(line) { 155 | if (!line) { 156 | // Empty line 157 | return; 158 | } 159 | 160 | this.emit('raw', { line: line, from_server: true }); 161 | 162 | const message = ircLineParser(line); 163 | if (!message) { 164 | return; 165 | } 166 | 167 | this.emit('message', message, line); 168 | } 169 | 170 | write(data, callback) { 171 | if (!this.connected || this.requested_disconnect) { 172 | this.debugOut('write() called when not connected'); 173 | 174 | if (callback) { 175 | setTimeout(callback, 0); // fire in next tick 176 | } 177 | 178 | return false; 179 | } 180 | 181 | this.emit('raw', { line: data, from_server: false }); 182 | return this.transport.writeLine(data, callback); 183 | } 184 | 185 | /** 186 | * Create and keep track of all timers so they can be easily removed 187 | */ 188 | setTimeout(/* fn, length, argN */) { 189 | const that = this; 190 | let tmr = null; 191 | const args = Array.prototype.slice.call(arguments, 0); 192 | const callback = args[0]; 193 | 194 | args[0] = function() { 195 | _.pull(that._timers, tmr); 196 | callback.apply(null, args); 197 | }; 198 | 199 | tmr = setTimeout.apply(null, args); 200 | this._timers.push(tmr); 201 | return tmr; 202 | } 203 | 204 | clearTimeout(tmr) { 205 | clearTimeout(tmr); 206 | _.pull(this._timers, tmr); 207 | } 208 | 209 | clearTimers() { 210 | this._timers.forEach(function(tmr) { 211 | clearTimeout(tmr); 212 | }); 213 | 214 | this._timers = []; 215 | } 216 | 217 | /** 218 | * Close the connection to the IRCd after forcing one last line 219 | */ 220 | end(data, had_error) { 221 | const that = this; 222 | 223 | this.debugOut('Connection.end() connected=' + this.connected + ' with data=' + !!data + ' had_error=' + !!had_error); 224 | 225 | if (this.connected && data) { 226 | // Once the last bit of data has been sent, then re-run this function to close the socket 227 | this.write(data, function() { 228 | that.end(null, had_error); 229 | }); 230 | 231 | return; 232 | } 233 | 234 | // Shutdowns of the connection may be caused by errors like ping timeouts, which 235 | // are not requested by the user so we leave requested_disconnect as false to make sure any 236 | // reconnects happen. 237 | if (!had_error) { 238 | this.requested_disconnect = true; 239 | this.clearTimers(); 240 | } 241 | 242 | if (this.transport) { 243 | this.transport.close(!!had_error); 244 | } 245 | } 246 | 247 | setEncoding(encoding) { 248 | this.debugOut('Connection.setEncoding() encoding=' + encoding); 249 | 250 | if (this.transport) { 251 | return this.transport.setEncoding(encoding); 252 | } 253 | } 254 | }; 255 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | map: require('lodash/map'), 5 | }; 6 | 7 | const Helper = { 8 | parseMask: parseMask, 9 | parseWhoFlags: parseWhoFlags, 10 | splitOnce: splitOnce, 11 | }; 12 | 13 | module.exports = Helper; 14 | 15 | function parseMask(mask) { 16 | let nick = ''; 17 | let user = ''; 18 | let host = ''; 19 | 20 | const sep1 = mask.indexOf('!'); 21 | const sep2 = mask.indexOf('@'); 22 | 23 | if (sep1 === -1 && sep2 === -1) { 24 | // something 25 | if (mask.indexOf('.') > -1) { 26 | host = mask; 27 | } else { 28 | nick = mask; 29 | } 30 | } else if (sep1 === -1 && sep2 !== -1) { 31 | // something@something 32 | nick = mask.substring(0, sep2); 33 | host = mask.substring(sep2 + 1); 34 | } else if (sep1 !== -1 && sep2 === -1) { 35 | // something!something 36 | nick = mask.substring(0, sep1); 37 | user = mask.substring(sep1 + 1); 38 | } else { 39 | // something!something@something 40 | nick = mask.substring(0, sep1); 41 | user = mask.substring(sep1 + 1, sep2); 42 | host = mask.substring(sep2 + 1); 43 | } 44 | 45 | return { 46 | nick: nick, 47 | user: user, 48 | host: host, 49 | }; 50 | } 51 | 52 | function parseWhoFlags(flagsParam, networkOptions) { 53 | // https://modern.ircdocs.horse/#rplwhoreply-352 54 | // unrealircd https://github.com/unrealircd/unrealircd/blob/8536778/doc/conf/help/help.conf#L429 55 | 56 | const unparsedFlags = flagsParam.split(''); 57 | 58 | // the flags object to be returned 59 | const parsedFlags = {}; 60 | 61 | // function to check for flags existence and remove it if existing 62 | const hasThenRemove = (flag) => { 63 | const flagIdx = unparsedFlags.indexOf(flag); 64 | if (flagIdx > -1) { 65 | unparsedFlags.splice(flagIdx, 1); 66 | return true; 67 | } 68 | return false; 69 | }; 70 | 71 | // away is represented by H = Here, G = Gone 72 | parsedFlags.away = !hasThenRemove('H'); 73 | parsedFlags.away = hasThenRemove('G'); 74 | 75 | // add bot mode if its flag is supported by the ircd 76 | const bot_mode_token = networkOptions.BOT; 77 | if (bot_mode_token) { 78 | parsedFlags.bot = hasThenRemove(bot_mode_token); 79 | } 80 | 81 | // common extended flags 82 | parsedFlags.registered = hasThenRemove('r'); 83 | parsedFlags.operator = hasThenRemove('*'); 84 | parsedFlags.secure = hasThenRemove('s'); 85 | 86 | // filter PREFIX array against the prefix's in who reply returning matched PREFIX objects 87 | const chan_prefixes = networkOptions.PREFIX.filter(f => hasThenRemove(f.symbol)); 88 | // use _.map to return an array of mode strings from matched PREFIX objects 89 | parsedFlags.channel_modes = _.map(chan_prefixes, 'mode'); 90 | 91 | return { parsedFlags, unparsedFlags }; 92 | } 93 | 94 | function splitOnce(input, separator) { 95 | if (typeof input !== 'string' || typeof separator !== 'string') { 96 | throw new TypeError('input and separator must be strings'); 97 | } 98 | 99 | let splitPos; 100 | 101 | if (separator === '') { 102 | // special handling required for empty string as separator 103 | 104 | // cannot match '' at start, so start searching after first character 105 | splitPos = input.indexOf(separator, 1); 106 | if (splitPos === input.length) { 107 | // cannot match '' at end, so if that's all we found, act like we found nothing 108 | splitPos = -1; 109 | } 110 | } else { 111 | // normal non-zero-length separator 112 | splitPos = input.indexOf(separator); 113 | } 114 | 115 | // no separator found 116 | if (splitPos < 0) { 117 | return [input]; 118 | } 119 | 120 | // the normal case: split around first instance of separator 121 | return [ 122 | input.slice(0, splitPos), 123 | input.slice(splitPos + separator.length), 124 | ]; 125 | } 126 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * The default irc-framework interface for nodejs 5 | * Usage: var IrcFramework = require('irc-framework'); 6 | */ 7 | 8 | module.exports.Client = require('./client'); 9 | module.exports.Client.setDefaultTransport(require('./transports/default')); 10 | 11 | module.exports.ircLineParser = require('./irclineparser'); 12 | module.exports.Message = require('./ircmessage'); 13 | module.exports.MessageTags = require('./messagetags'); 14 | module.exports.Helpers = require('./helpers'); 15 | 16 | module.exports.Channel = require('./channel'); 17 | -------------------------------------------------------------------------------- /src/irclineparser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const MessageTags = require('./messagetags'); 4 | const IrcMessage = require('./ircmessage'); 5 | const helpers = require('./helpers'); 6 | 7 | module.exports = parseIrcLine; 8 | 9 | const newline_regex = /^[\r\n]+|[\r\n]+$/g; 10 | 11 | function parseIrcLine(input_) { 12 | const input = input_.replace(newline_regex, ''); 13 | let cPos = 0; 14 | let inParams = false; 15 | 16 | const nextToken = () => { 17 | // Fast forward to somewhere with actual data 18 | while (input[cPos] === ' ' && cPos < input.length) { 19 | cPos++; 20 | } 21 | 22 | if (cPos === input.length) { 23 | // If reading the params then return null to indicate no more params available. 24 | // The trailing parameter may be empty but should still be included as an empty string. 25 | return inParams ? null : ''; 26 | } 27 | 28 | let end = input.indexOf(' ', cPos); 29 | if (end === -1) { 30 | // No more spaces means were on the last token 31 | end = input.length; 32 | } 33 | 34 | if (inParams && input[cPos] === ':' && input[cPos - 1] === ' ') { 35 | // If a parameter start with : then we're in the last parameter which may incude spaces 36 | cPos++; 37 | end = input.length; 38 | } 39 | 40 | const token = input.substring(cPos, end); 41 | cPos = end; 42 | 43 | // Fast forward our current position so we can peek what's next via input[cPos] 44 | while (input[cPos] === ' ' && cPos < input.length) { 45 | cPos++; 46 | } 47 | 48 | return token; 49 | }; 50 | 51 | const ret = new IrcMessage(); 52 | 53 | if (input[cPos] === '@') { 54 | ret.tags = MessageTags.decode(nextToken().substr(1)); 55 | } 56 | 57 | if (input[cPos] === ':') { 58 | ret.prefix = nextToken().substr(1); 59 | const mask = helpers.parseMask(ret.prefix); 60 | ret.nick = mask.nick; 61 | ret.ident = mask.user; 62 | ret.hostname = mask.host; 63 | } 64 | 65 | ret.command = nextToken().toUpperCase(); 66 | 67 | inParams = true; 68 | 69 | let token = nextToken(); 70 | while (token !== null) { 71 | ret.params.push(token); 72 | token = nextToken(); 73 | } 74 | 75 | return ret; 76 | } 77 | -------------------------------------------------------------------------------- /src/ircmessage.js: -------------------------------------------------------------------------------- 1 | const MessageTags = require('./messagetags'); 2 | 3 | module.exports = class IrcMessage { 4 | constructor(command, ...args) { 5 | this.tags = Object.create(null); 6 | this.prefix = ''; 7 | this.nick = ''; 8 | this.ident = ''; 9 | this.hostname = ''; 10 | this.command = command || ''; 11 | this.params = args || []; 12 | } 13 | 14 | to1459() { 15 | const parts = []; 16 | 17 | const tags = MessageTags.encode(this.tags); 18 | if (tags) { 19 | parts.push('@' + tags); 20 | } 21 | 22 | if (this.prefix) { 23 | // TODO: If prefix is empty, build it from the nick!ident@hostname 24 | parts.push(':' + this.prefix); 25 | } 26 | 27 | parts.push(this.command); 28 | 29 | if (this.params.length > 0) { 30 | this.params.forEach((param, idx) => { 31 | if (idx === this.params.length - 1 && (param.indexOf(' ') > -1 || param[0] === ':')) { 32 | parts.push(':' + param); 33 | } else { 34 | parts.push(param); 35 | } 36 | }); 37 | } 38 | 39 | return parts.join(' '); 40 | } 41 | 42 | toJson() { 43 | return { 44 | tags: Object.assign({}, this.tags), 45 | source: this.prefix, 46 | command: this.command, 47 | params: this.params, 48 | }; 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/linebreak.js: -------------------------------------------------------------------------------- 1 | const GraphemeSplitter = require('grapheme-splitter'); 2 | const { encode: encodeUTF8 } = require('isomorphic-textencoder'); 3 | 4 | const graphemeSplitter = new GraphemeSplitter(); 5 | 6 | /* abstract */ class SubstringTooLargeForLineError extends Error { 7 | /* substring: string */ 8 | /* opts: Options */ 9 | 10 | constructor(substring/* : string */, opts/* : Options */) { 11 | super(); 12 | 13 | // Maintains proper stack trace for where our error was thrown (only available on V8) 14 | // @ts-ignore 15 | if (Error.captureStackTrace) { 16 | // @ts-ignore 17 | Error.captureStackTrace(this, this.constructor); 18 | } 19 | 20 | // Custom debugging information 21 | this.substring = substring; 22 | this.opts = opts; 23 | } 24 | 25 | get name() { 26 | return this.constructor.name; 27 | } 28 | } 29 | 30 | class WordTooLargeForLineError extends SubstringTooLargeForLineError { 31 | get message() { 32 | return `${size(this.substring)} byte word can't fit in a ${this.opts.bytes} byte block: ${this.substring}`; 33 | } 34 | } 35 | 36 | class GraphemeTooLargeForLineError extends SubstringTooLargeForLineError { 37 | get message() { 38 | return `${size(this.substring)} byte grapheme can't fit in a ${this.opts.bytes} byte block: ${this.substring}`; 39 | } 40 | } 41 | 42 | class CodepointTooLargeForLineError extends SubstringTooLargeForLineError { 43 | get message() { 44 | return `${size(this.substring)} byte codepoint can't fit in a ${this.opts.bytes} byte block: ${this.substring}`; 45 | } 46 | } 47 | 48 | function size(str/* : string */)/* : number */ { 49 | const byteArray = encodeUTF8(str); 50 | const bytes = byteArray.byteLength; 51 | return bytes; 52 | } 53 | 54 | /* export interface Options { 55 | bytes: number, 56 | allowBreakingWords?: boolean, 57 | allowBreakingGraphemes?: boolean, 58 | } */ 59 | 60 | function * lineBreak(str/* : string */, opts/* : Options */)/* : IterableIterator */ { 61 | let line = ''; 62 | let previousWhitespace = ''; 63 | 64 | for (const [word, trailingWhitespace] of wordBreak(str)) { 65 | // word fits in current line 66 | if (size(line) + size(previousWhitespace) + size(word) <= opts.bytes) { 67 | line += previousWhitespace + word; 68 | previousWhitespace = trailingWhitespace; 69 | continue; 70 | } 71 | 72 | // can fit word in a line by itself 73 | if (size(word) <= opts.bytes) { 74 | if (line) { 75 | yield line; // yield previously built up line 76 | } 77 | 78 | // previously buffered whitespace is discarded as it was replaced by a line break 79 | // store new whitespace for later 80 | previousWhitespace = trailingWhitespace; 81 | 82 | line = word; // next line starts with word 83 | continue; 84 | } 85 | 86 | // can't fit word into a line by itself 87 | if (!opts.allowBreakingWords) { 88 | throw new WordTooLargeForLineError(word, opts); 89 | } 90 | 91 | // try to fit part of word into current line 92 | const wordPreviousWhitespace = trailingWhitespace; 93 | for (const grapheme of graphemeSplitter.iterateGraphemes(word)) { 94 | // can fit next grapheme 95 | if (size(line) + size(previousWhitespace) + size(grapheme) <= opts.bytes) { 96 | line += previousWhitespace + grapheme; 97 | previousWhitespace = ''; 98 | continue; 99 | } 100 | 101 | // can fit next grapheme into a line by itself 102 | if (size(grapheme) <= opts.bytes) { 103 | if (line) { 104 | yield line; 105 | } 106 | previousWhitespace = ''; 107 | line = grapheme; 108 | continue; 109 | } 110 | 111 | // grapheme can't fit in a single line 112 | if (!opts.allowBreakingGraphemes) { 113 | throw new GraphemeTooLargeForLineError(grapheme, opts); 114 | } 115 | 116 | // break grapheme into codepoints instead 117 | for (const codepoint of grapheme) { 118 | // can fit codepoint into current line 119 | if (size(line) + size(previousWhitespace) + size(codepoint) <= opts.bytes) { 120 | line += previousWhitespace + codepoint; 121 | previousWhitespace = ''; 122 | continue; 123 | } 124 | 125 | // can fit codepoint into its own line 126 | if (size(codepoint) <= opts.bytes) { 127 | if (line) { 128 | yield line; 129 | } 130 | previousWhitespace = ''; 131 | line = codepoint; 132 | continue; 133 | } 134 | 135 | // can't fit codepoint into its own line 136 | throw new CodepointTooLargeForLineError(codepoint, opts); 137 | } // end of codepoint loop 138 | } // end of grapheme loop 139 | previousWhitespace = wordPreviousWhitespace; 140 | } // end of [word, trailingWhitespace] loop 141 | 142 | // unyielded leftovers when we're done iterating over the input string 143 | if (previousWhitespace) { 144 | if (size(line) + size(previousWhitespace) <= opts.bytes) { 145 | line += previousWhitespace; // retain trailing whitespace on input line if possible 146 | } 147 | } 148 | if (line) { 149 | yield line; 150 | } 151 | } 152 | 153 | // yields [word, trailingWhitespace] tuples 154 | function * wordBreak(str/* : string */)/* : IterableIterator<[string, string]> */ { 155 | let word = ''; 156 | let trailingWhitespace = ''; 157 | 158 | for (const grapheme of graphemeSplitter.iterateGraphemes(str)) { 159 | // grapheme is whitespace 160 | if (/^\s+$/.test(grapheme)) { 161 | // collect whitespace 162 | trailingWhitespace += grapheme; 163 | continue; 164 | } 165 | 166 | // grapheme is non-whitespace 167 | 168 | // start of new word 169 | if (trailingWhitespace) { 170 | yield [word, trailingWhitespace]; 171 | word = grapheme; 172 | trailingWhitespace = ''; 173 | continue; 174 | } 175 | 176 | // continuation of word 177 | word += grapheme; 178 | } 179 | 180 | // possible leftovers at end of input string 181 | if (word) { 182 | yield [word, trailingWhitespace]; 183 | } 184 | // trailingWhitespace can't be non-empty unless word is non-empty 185 | } 186 | 187 | module.exports = { 188 | WordTooLargeForLineError, 189 | GraphemeTooLargeForLineError, 190 | CodepointTooLargeForLineError, 191 | lineBreak, 192 | wordBreak, 193 | }; 194 | -------------------------------------------------------------------------------- /src/messagetags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Helpers = require('./helpers'); 4 | 5 | module.exports.decodeValue = decodeValue; 6 | module.exports.encodeValue = encodeValue; 7 | module.exports.decode = decode; 8 | module.exports.encode = encode; 9 | 10 | const tokens_map = { 11 | '\\\\': '\\', 12 | '\\:': ';', 13 | '\\s': ' ', 14 | '\\n': '\n', 15 | '\\r': '\r', 16 | '\\': '', // remove invalid backslashes 17 | }; 18 | 19 | const token_lookup = /\\\\|\\:|\\s|\\n|\\r|\\/gi; 20 | 21 | function decodeValue(value) { 22 | return value.replace(token_lookup, m => tokens_map[m] || ''); 23 | } 24 | 25 | const vals_map = { 26 | '\\': '\\\\', 27 | ';': '\\:', 28 | ' ': '\\s', 29 | '\n': '\\n', 30 | '\r': '\\r', 31 | }; 32 | 33 | const val_lookup = /\\|;| |\n|\r/gi; 34 | 35 | function encodeValue(value) { 36 | return value.replace(val_lookup, m => vals_map[m] || ''); 37 | } 38 | 39 | function decode(tag_str) { 40 | const tags = Object.create(null); 41 | 42 | tag_str.split(';').forEach(tag => { 43 | const parts = Helpers.splitOnce(tag, '='); 44 | const key = parts[0].toLowerCase(); 45 | let value = parts[1] || ''; 46 | 47 | if (!key) { 48 | return; 49 | } 50 | 51 | value = decodeValue(value); 52 | tags[key] = value; 53 | }); 54 | 55 | return tags; 56 | } 57 | 58 | function encode(tags, separator = ';') { 59 | const parts = Object.keys(tags).map(key => { 60 | const val = tags[key]; 61 | 62 | if (typeof val === 'boolean') { 63 | return key; 64 | } 65 | 66 | return key + '=' + encodeValue(val.toString()); 67 | }); 68 | 69 | return parts.join(separator); 70 | } 71 | -------------------------------------------------------------------------------- /src/networkinfo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = { 4 | find: require('lodash/find'), 5 | }; 6 | 7 | module.exports = NetworkInfo; 8 | 9 | function NetworkInfo() { 10 | // Name of the network 11 | this.name = 'Network'; 12 | 13 | // Name of the connected server 14 | this.server = ''; 15 | 16 | // The reported IRCd type 17 | this.ircd = ''; 18 | 19 | // Network provided options 20 | this.options = { 21 | CASEMAPPING: 'rfc1459', 22 | PREFIX: [ 23 | { symbol: '~', mode: 'q' }, 24 | { symbol: '&', mode: 'a' }, 25 | { symbol: '@', mode: 'o' }, 26 | { symbol: '%', mode: 'h' }, 27 | { symbol: '+', mode: 'v' } 28 | ], 29 | }; 30 | 31 | // Network capabilities 32 | this.cap = { 33 | negotiating: false, 34 | requested: [], 35 | enabled: [], 36 | available: new Map(), 37 | isEnabled: function(cap_name) { 38 | return this.enabled.indexOf(cap_name) > -1; 39 | } 40 | }; 41 | 42 | this.time_offsets = []; 43 | this.time_offset = 0; 44 | 45 | this.timeToLocal = function timeToLocal(serverTimeMs) { 46 | return serverTimeMs - this.getServerTimeOffset(); 47 | }; 48 | 49 | this.timeToServer = function timeToServer(localTimeMs) { 50 | return localTimeMs + this.getServerTimeOffset(); 51 | }; 52 | 53 | this.getServerTimeOffset = function getServerTimeOffset() { 54 | const sortedOffsets = this.time_offsets.slice(0).sort(function(a, b) { return a - b; }); 55 | return sortedOffsets[Math.floor(this.time_offsets.length / 2)] || 0; 56 | }; 57 | 58 | this.addServerTimeOffset = function addServerTimeOffset(time) { 59 | // add our new offset 60 | const newOffset = time - Date.now(); 61 | this.time_offsets.push(newOffset); 62 | 63 | // limit out offsets array to 7 enteries 64 | if (this.time_offsets.length > 7) { 65 | this.time_offsets = this.time_offsets.slice(this.time_offsets.length - 7); 66 | } 67 | 68 | const currentOffset = this.getServerTimeOffset(); 69 | if (newOffset - currentOffset > 2000 || newOffset - currentOffset < -2000) { 70 | // skew was over 2 seconds, invalidate all but last offset 71 | // > 2sec skew is a little large so just use that. Possible 72 | // that the time on the IRCd actually changed 73 | this.time_offsets = this.time_offsets.slice(-1); 74 | } 75 | 76 | this.time_offset = this.getServerTimeOffset(); 77 | }; 78 | 79 | this.supports = function supports(support_name) { 80 | return this.options[support_name.toUpperCase()]; 81 | }; 82 | 83 | this.supportsTag = function supportsTag(tag_name) { 84 | if (!this.cap.isEnabled('message-tags')) { 85 | return false; 86 | } 87 | 88 | if (!this.options.CLIENTTAGDENY || this.options.CLIENTTAGDENY.length === 0) { 89 | return true; 90 | } 91 | 92 | const allowAll = this.options.CLIENTTAGDENY[0] !== '*'; 93 | if (allowAll) { 94 | return !this.options.CLIENTTAGDENY.some((tag) => tag === tag_name); 95 | } 96 | 97 | return this.options.CLIENTTAGDENY.some((tag) => tag === `-${tag_name}`); 98 | }; 99 | 100 | this.isChannelName = function isChannelName(channel_name) { 101 | if (typeof channel_name !== 'string' || channel_name === '') { 102 | return false; 103 | } 104 | const chanPrefixes = this.supports('CHANTYPES') || '&#'; 105 | return chanPrefixes.indexOf(channel_name[0]) > -1; 106 | }; 107 | 108 | // Support '@#channel' and '++channel' formats 109 | this.extractTargetGroup = function extractTargetGroup(target) { 110 | const statusMsg = this.supports('STATUSMSG'); 111 | 112 | if (!statusMsg) { 113 | return null; 114 | } 115 | 116 | const target_group = _.find(statusMsg, function(prefix) { 117 | if (prefix === target[0]) { 118 | target = target.substring(1); 119 | 120 | return prefix; 121 | } 122 | }); 123 | 124 | if (!target_group) { 125 | return null; 126 | } 127 | 128 | return { 129 | target: target, 130 | target_group: target_group, 131 | }; 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/transports/default.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./net'); 4 | -------------------------------------------------------------------------------- /src/transports/default_browser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./websocket'); 4 | -------------------------------------------------------------------------------- /src/transports/net.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * TCP / TLS transport 5 | */ 6 | 7 | const net = require('net'); 8 | const tls = require('tls'); 9 | const EventEmitter = require('events').EventEmitter; 10 | const SocksClient = require('socks').SocksClient; 11 | const iconv = require('iconv-lite'); 12 | 13 | const SOCK_DISCONNECTED = 0; 14 | const SOCK_CONNECTING = 1; 15 | const SOCK_CONNECTED = 2; 16 | 17 | module.exports = class Connection extends EventEmitter { 18 | constructor(options) { 19 | super(); 20 | 21 | this.options = options || {}; 22 | 23 | this.socket = null; 24 | this.state = SOCK_DISCONNECTED; 25 | this.last_socket_error = null; 26 | this.socket_events = []; 27 | 28 | this.encoding = 'utf8'; 29 | } 30 | 31 | isConnected() { 32 | return this.state === SOCK_CONNECTED; 33 | } 34 | 35 | writeLine(line, cb) { 36 | if (this.socket && this.isConnected()) { 37 | if (this.encoding !== 'utf8') { 38 | this.socket.write(iconv.encode(line + '\r\n', this.encoding), cb); 39 | } else { 40 | this.socket.write(line + '\r\n', cb); 41 | } 42 | } else { 43 | this.debugOut('writeLine() called when not connected'); 44 | 45 | if (cb) { 46 | process.nextTick(cb); 47 | } 48 | } 49 | } 50 | 51 | debugOut(out) { 52 | this.emit('debug', 'NetTransport ' + out); 53 | } 54 | 55 | _bindEvent(obj, event, fn) { 56 | obj.on(event, fn); 57 | const unbindEvent = () => { 58 | obj.off(event, fn); 59 | }; 60 | this.socket_events.push(unbindEvent); 61 | return unbindEvent; 62 | } 63 | 64 | _unbindEvents() { 65 | this.socket_events.forEach(fn => fn()); 66 | } 67 | 68 | connect() { 69 | const options = this.options; 70 | const ircd_host = options.host; 71 | const ircd_port = options.port || 6667; 72 | let sni; 73 | 74 | this.debugOut('connect()'); 75 | 76 | this.disposeSocket(); 77 | this.requested_disconnect = false; 78 | this.incoming_buffer = Buffer.from(''); 79 | 80 | // Include server name (SNI) if provided host is not an IP address 81 | if (!this.getAddressFamily(ircd_host)) { 82 | sni = ircd_host; 83 | } 84 | 85 | if (!options.encoding || !this.setEncoding(options.encoding)) { 86 | this.setEncoding('utf8'); 87 | } 88 | 89 | this.state = SOCK_CONNECTING; 90 | this.debugOut('Connecting socket..'); 91 | 92 | if (options.socks) { 93 | this.debugOut('Using SOCKS proxy'); 94 | this.socket = null; 95 | 96 | SocksClient.createConnection({ 97 | proxy: { 98 | host: options.socks.host, 99 | port: options.socks.port || 8080, 100 | type: 5, // Proxy version (4 or 5) 101 | 102 | userId: options.socks.user, 103 | password: options.socks.pass, 104 | }, 105 | 106 | command: 'connect', 107 | 108 | destination: { 109 | host: ircd_host, 110 | port: ircd_port, 111 | } 112 | }).then(info => { 113 | let connection = info.socket; 114 | if (options.tls || options.ssl) { 115 | connection = tls.connect({ 116 | socket: connection, 117 | servername: sni, 118 | rejectUnauthorized: options.rejectUnauthorized, 119 | key: options.client_certificate && options.client_certificate.private_key, 120 | cert: options.client_certificate && options.client_certificate.certificate, 121 | }); 122 | } 123 | this.socket = connection; 124 | this.debugOut('SOCKS connection established.'); 125 | this._onSocketCreate(options, connection); 126 | }).catch(this.onSocketError.bind(this)); 127 | } else { 128 | let socket = null; 129 | if (options.tls || options.ssl) { 130 | socket = this.socket = tls.connect({ 131 | servername: sni, 132 | host: ircd_host, 133 | port: ircd_port, 134 | rejectUnauthorized: options.rejectUnauthorized, 135 | key: options.client_certificate && options.client_certificate.private_key, 136 | cert: options.client_certificate && options.client_certificate.certificate, 137 | localAddress: options.outgoing_addr, 138 | family: this.getAddressFamily(options.outgoing_addr) 139 | }); 140 | } else { 141 | socket = this.socket = net.connect({ 142 | host: ircd_host, 143 | port: ircd_port, 144 | localAddress: options.outgoing_addr, 145 | family: this.getAddressFamily(options.outgoing_addr) 146 | }); 147 | } 148 | this._onSocketCreate(options, socket); 149 | } 150 | } 151 | 152 | _onSocketCreate(options, socket) { 153 | this.debugOut('Socket created!'); 154 | if (options.ping_interval > 0 && options.ping_timeout > 0) { 155 | socket.setTimeout((options.ping_interval + options.ping_timeout) * 1000); 156 | } 157 | 158 | // We need the raw socket connect event. 159 | // It seems SOCKS gives us the socket in an already open state! Deal with that: 160 | if (socket.readyState !== 'opening') { 161 | this.onSocketRawConnected(); 162 | if (!(socket instanceof tls.TLSSocket)) { 163 | this.onSocketFullyConnected(); 164 | } 165 | } else { 166 | this._bindEvent(socket, 'connect', this.onSocketRawConnected.bind(this)); 167 | } 168 | this._bindEvent( 169 | socket, 170 | socket instanceof tls.TLSSocket ? 'secureConnect' : 'connect', 171 | this.onSocketFullyConnected.bind(this) 172 | ); 173 | this._bindEvent(socket, 'close', this.onSocketClose.bind(this)); 174 | this._bindEvent(socket, 'error', this.onSocketError.bind(this)); 175 | this._bindEvent(socket, 'data', this.onSocketData.bind(this)); 176 | this._bindEvent(socket, 'timeout', this.onSocketTimeout.bind(this)); 177 | } 178 | 179 | // Called when the socket is connected and before any TLS handshaking if applicable. 180 | // This is when it's ideal to read socket pairs for identd. 181 | onSocketRawConnected() { 182 | this.debugOut('socketRawConnected()'); 183 | this.state = SOCK_CONNECTED; 184 | this.emit('extra', 'raw socket connected', (this.socket.socket || this.socket)); 185 | } 186 | 187 | // Called when the socket is connected and ready to start sending/receiving data. 188 | onSocketFullyConnected() { 189 | this.debugOut('socketFullyConnected()'); 190 | this.last_socket_error = null; 191 | this.emit('open'); 192 | } 193 | 194 | onSocketClose() { 195 | this.debugOut('socketClose()'); 196 | this.state = SOCK_DISCONNECTED; 197 | this.emit('close', this.last_socket_error ? this.last_socket_error : false); 198 | } 199 | 200 | onSocketError(err) { 201 | this.debugOut('socketError() ' + err.message); 202 | this.last_socket_error = err; 203 | // this.emit('error', err); 204 | } 205 | 206 | onSocketTimeout() { 207 | this.debugOut('socketTimeout()'); 208 | this.close(true); 209 | } 210 | 211 | onSocketData(data) { 212 | // Buffer incoming data because multiple messages can arrive at once 213 | // without necessarily ending in a new line 214 | this.incoming_buffer = Buffer.concat( 215 | [this.incoming_buffer, data], 216 | this.incoming_buffer.length + data.length 217 | ); 218 | 219 | let startIndex = 0; 220 | 221 | while (true) { 222 | // Search for the next new line in the buffered data 223 | const endIndex = this.incoming_buffer.indexOf(0x0A, startIndex) + 1; 224 | 225 | // If this message is partial, keep it in the buffer until more data arrives. 226 | // If startIndex is equal to incoming_buffer.length, that means we reached the end 227 | // of the buffer and it ended on a new line, slice will return an empty buffer. 228 | if (endIndex === 0) { 229 | this.incoming_buffer = this.incoming_buffer.slice(startIndex); 230 | break; 231 | } 232 | 233 | // Slice a single message delimited by a new line, decode it and emit it out 234 | let line = this.incoming_buffer.slice(startIndex, endIndex); 235 | line = iconv.decode(line, this.encoding); 236 | this.emit('line', line); 237 | 238 | startIndex = endIndex; 239 | } 240 | } 241 | 242 | disposeSocket() { 243 | this.debugOut('disposeSocket() connected=' + this.isConnected()); 244 | 245 | if (this.socket && this.state !== SOCK_DISCONNECTED) { 246 | this.socket.destroy(); 247 | } 248 | 249 | if (this.socket) { 250 | this._unbindEvents(); 251 | this.socket = null; 252 | } 253 | } 254 | 255 | close(force) { 256 | if (!this.socket) { 257 | this.debugOut('close() called with no socket'); 258 | return; 259 | } 260 | 261 | // Cleanly close the socket if we can 262 | if (this.state === SOCK_CONNECTING || force) { 263 | this.debugOut('close() destroying'); 264 | this.socket.destroy(); 265 | } else if (this.state === SOCK_CONNECTED) { 266 | this.debugOut('close() ending'); 267 | this.socket.end(); 268 | } 269 | } 270 | 271 | setEncoding(encoding) { 272 | let encoded_test; 273 | 274 | this.debugOut('Connection.setEncoding() encoding=' + encoding); 275 | 276 | try { 277 | const testString = 'TEST\r\ntest'; 278 | 279 | encoded_test = iconv.encode(testString, encoding); 280 | // This test is done to check if this encoding also supports 281 | // the ASCII charset required by the IRC protocols 282 | // (Avoid the use of base64 or incompatible encodings) 283 | if (encoded_test.toString('ascii') === testString) { 284 | this.encoding = encoding; 285 | return true; 286 | } 287 | return false; 288 | } catch (err) { 289 | return false; 290 | } 291 | } 292 | 293 | getAddressFamily(addr) { 294 | if (net.isIPv4(addr)) { 295 | return 4; 296 | } 297 | if (net.isIPv6(addr)) { 298 | return 6; 299 | } 300 | } 301 | }; 302 | -------------------------------------------------------------------------------- /src/transports/websocket.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Websocket transport 5 | */ 6 | 7 | const EventEmitter = require('eventemitter3'); 8 | 9 | module.exports = class Connection extends EventEmitter { 10 | constructor(options) { 11 | super(); 12 | 13 | this.options = options || {}; 14 | 15 | this.socket = null; 16 | this.connected = false; 17 | this.last_socket_error = null; 18 | 19 | this.encoding = 'utf8'; 20 | this.incoming_buffer = ''; 21 | 22 | this.protocol_fallback = false; 23 | 24 | // JSON does not allow undefined and websocket protocol does not allow falsy 25 | // if the protocol is falsy then the user intends no protocol, so set to undefined 26 | this.protocol = options.websocket_protocol ? 27 | options.websocket_protocol : 28 | undefined; 29 | } 30 | 31 | isConnected() { 32 | return this.connected; 33 | } 34 | 35 | writeLine(line, cb) { 36 | this.debugOut('writeLine() socket=' + (this.socket ? 'yes' : 'no') + ' connected=' + this.connected); 37 | 38 | if (this.socket && this.connected) { 39 | this.socket.send(line); 40 | } 41 | 42 | // Websocket.send() does not support callbacks 43 | // call the callback in the next tick instead 44 | if (cb) { 45 | setTimeout(cb, 0); 46 | } 47 | } 48 | 49 | debugOut(out) { 50 | this.emit('debug', out); 51 | } 52 | 53 | connect() { 54 | const that = this; 55 | const options = this.options; 56 | let socket = null; 57 | let ws_addr = ''; 58 | 59 | this.debugOut('Connection.connect()'); 60 | 61 | this.disposeSocket(); 62 | this.requested_disconnect = false; 63 | 64 | // Build the websocket address. eg. ws://ws.rizon.net:8080 65 | ws_addr += (options.tls || options.ssl) ? 'wss://' : 'ws://'; 66 | ws_addr += options.host; 67 | ws_addr += options.port ? ':' + options.port : ''; 68 | ws_addr += options.path ? options.path : ''; 69 | 70 | socket = this.socket = new WebSocket(ws_addr, this.protocol); 71 | 72 | socket.onopen = function() { 73 | that.onSocketFullyConnected(); 74 | }; 75 | socket.onclose = function(event) { 76 | that.onSocketClose(event); 77 | }; 78 | socket.onmessage = function(event) { 79 | that.onSocketMessage(event.data); 80 | }; 81 | socket.onerror = function(err) { 82 | that.debugOut('socketError() ' + err.message); 83 | that.last_socket_error = err; 84 | }; 85 | } 86 | 87 | // Called when the socket is connected and ready to start sending/receiving data. 88 | onSocketFullyConnected() { 89 | this.debugOut('socketFullyConnected()'); 90 | this.last_socket_error = null; 91 | this.connected = true; 92 | this.emit('open'); 93 | } 94 | 95 | onSocketClose(event) { 96 | const possible_protocol_error = !this.connected && event.code === 1006; 97 | if (possible_protocol_error && !this.protocol_fallback && this.protocol !== undefined) { 98 | // First connection attempt failed possibly due to mismatched protocol, 99 | // retry the connection with undefined protocol 100 | // After this attempt, normal reconnect functions apply which will 101 | // reconstruct this websocket, resetting these variables 102 | this.debugOut('socketClose() possible protocol error, retrying with no protocol'); 103 | this.protocol_fallback = true; 104 | this.protocol = undefined; 105 | this.connect(); 106 | return; 107 | } 108 | 109 | this.debugOut('socketClose()'); 110 | this.connected = false; 111 | this.emit('close', this.last_socket_error ? this.last_socket_error : false); 112 | } 113 | 114 | onSocketMessage(data) { 115 | if (typeof data !== 'string') { 116 | this.last_socket_error = new Error('Websocket received unexpected binary data, closing the connection'); 117 | this.debugOut('socketData() ' + this.last_socket_error.message); 118 | this.close(); 119 | return; 120 | } 121 | 122 | this.debugOut('socketData() ' + JSON.stringify(data)); 123 | 124 | const that = this; 125 | let lines = null; 126 | 127 | this.incoming_buffer += data + '\n'; 128 | 129 | lines = this.incoming_buffer.split('\n'); 130 | if (lines[lines.length - 1] !== '') { 131 | this.incoming_buffer = lines.pop(); 132 | } else { 133 | lines.pop(); 134 | this.incoming_buffer = ''; 135 | } 136 | 137 | lines.forEach(function(line) { 138 | that.emit('line', line); 139 | }); 140 | } 141 | 142 | disposeSocket() { 143 | this.debugOut('Connection.disposeSocket() connected=' + this.connected); 144 | 145 | if (this.socket && this.connected) { 146 | this.socket.close(); 147 | } 148 | 149 | if (this.socket) { 150 | this.socket.onopen = null; 151 | this.socket.onclose = null; 152 | this.socket.onmessage = null; 153 | this.socket.onerror = null; 154 | this.socket = null; 155 | } 156 | } 157 | 158 | close() { 159 | if (this.socket && this.connected) { 160 | this.socket.close(); 161 | } 162 | } 163 | 164 | setEncoding(encoding) { 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /src/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = class User { 4 | constructor(opts) { 5 | opts = opts || {}; 6 | 7 | this.nick = opts.nick || ''; 8 | this.username = opts.username || ''; 9 | this.gecos = opts.gecos || ''; 10 | this.host = opts.host || ''; 11 | this.away = !!opts.away; 12 | 13 | this.modes = new Set(opts.modes || []); 14 | } 15 | 16 | toggleModes(modestr) { 17 | let adding = true; 18 | let i; 19 | 20 | for (i in modestr) { 21 | switch (modestr[i]) { 22 | case '+': 23 | adding = true; 24 | break; 25 | case '-': 26 | adding = false; 27 | break; 28 | default: 29 | this.modes[adding ? 'add' : 'delete'](modestr[i]); 30 | } 31 | } 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /test/casefolding.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* globals describe, it */ 3 | const chai = require('chai'); 4 | const IrcClient = require('../src/client'); 5 | const expect = chai.expect; 6 | 7 | chai.use(require('chai-subset')); 8 | 9 | describe('src/client.js', function() { 10 | describe('caseLower', function() { 11 | it('CASEMAPPING=rfc1459', function() { 12 | const client = new IrcClient(); 13 | 14 | expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default 15 | expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); 16 | expect(client.caseLower('ÀTEST[]^\\')).to.equal('Àtest{}~|'); 17 | expect(client.caseLower('Àtest{}~|')).to.equal('Àtest{}~|'); 18 | expect(client.caseLower('@?A_`#&')).to.equal('@?a_`#&'); 19 | }); 20 | 21 | it('CASEMAPPING=strict-rfc1459', function() { 22 | const client = new IrcClient(); 23 | client.network.options.CASEMAPPING = 'strict-rfc1459'; 24 | 25 | expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); 26 | expect(client.caseLower('ÀTEST[]^\\')).to.equal('Àtest{}^|'); 27 | expect(client.caseLower('Àtest{}^|')).to.equal('Àtest{}^|'); 28 | expect(client.caseLower('@?A^_`#&')).to.equal('@?a^_`#&'); 29 | }); 30 | 31 | it('CASEMAPPING=ascii', function() { 32 | const client = new IrcClient(); 33 | client.network.options.CASEMAPPING = 'ascii'; 34 | 35 | expect(client.caseLower('ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.equal('abcdefghijklmnopqrstuvwxyz'); 36 | expect(client.caseLower('ÀTEST[]^\\{}~|#&')).to.equal('Àtest[]^\\{}~|#&'); 37 | expect(client.caseLower('ПРИВЕТ, как дела? 👋')).to.equal('ПРИВЕТ, как дела? 👋'); 38 | }); 39 | }); 40 | 41 | describe('caseUpper', function() { 42 | it('CASEMAPPING=rfc1459', function() { 43 | const client = new IrcClient(); 44 | 45 | expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default 46 | expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); 47 | expect(client.caseUpper('ÀTEST{}~|')).to.equal('ÀTEST[]^\\'); 48 | expect(client.caseUpper('ÀTEST[]^\\')).to.equal('ÀTEST[]^\\'); 49 | expect(client.caseUpper('@?a_`#&')).to.equal('@?A_`#&'); 50 | }); 51 | 52 | it('CASEMAPPING=strict-rfc1459', function() { 53 | const client = new IrcClient(); 54 | client.network.options.CASEMAPPING = 'strict-rfc1459'; 55 | 56 | expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); 57 | expect(client.caseUpper('ÀTEST{}~|')).to.equal('ÀTEST[]~\\'); 58 | expect(client.caseUpper('ÀTEST[]^\\')).to.equal('ÀTEST[]^\\'); 59 | expect(client.caseUpper('@?a^~_`#&')).to.equal('@?A^~_`#&'); 60 | }); 61 | 62 | it('CASEMAPPING=ascii', function() { 63 | const client = new IrcClient(); 64 | client.network.options.CASEMAPPING = 'ascii'; 65 | 66 | expect(client.caseUpper('abcdefghijklmnopqrstuvwxyz')).to.equal('ABCDEFGHIJKLMNOPQRSTUVWXYZ'); 67 | expect(client.caseUpper('Àtest[]^\\{}~|#&')).to.equal('ÀTEST[]^\\{}~|#&'); 68 | expect(client.caseUpper('ПРИВЕТ, как дела? 👋')).to.equal('ПРИВЕТ, как дела? 👋'); 69 | }); 70 | }); 71 | 72 | /* eslint-disable no-unused-expressions */ 73 | describe('caseCompare', function() { 74 | it('CASEMAPPING=rfc1459', function() { 75 | const client = new IrcClient(); 76 | 77 | expect(client.network.options.CASEMAPPING).to.equal('rfc1459'); // default 78 | 79 | expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; 80 | expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; 81 | expect(client.caseCompare('Àtest{}~|', 'ÀTEST[]^\\')).to.be.true; 82 | expect(client.caseCompare('ÀTEST[]^\\', 'Àtest{}~|')).to.be.true; 83 | expect(client.caseCompare('Àtest{}~|', 'Àtest{}~|')).to.be.true; 84 | expect(client.caseCompare('@?A_`#&', '@?a_`#&')).to.be.true; 85 | }); 86 | 87 | it('CASEMAPPING=strict-rfc1459', function() { 88 | const client = new IrcClient(); 89 | client.network.options.CASEMAPPING = 'strict-rfc1459'; 90 | 91 | expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; 92 | expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; 93 | expect(client.caseCompare('Àtest{}^|', 'ÀTEST[]^\\')).to.be.true; 94 | expect(client.caseCompare('ÀTEST[]^\\', 'Àtest{}^|')).to.be.true; 95 | expect(client.caseCompare('Àtest{}^|', 'Àtest{}^|')).to.be.true; 96 | expect(client.caseCompare('@?A^_`#&', '@?a^_`#&')).to.be.true; 97 | }); 98 | 99 | it('CASEMAPPING=ascii', function() { 100 | const client = new IrcClient(); 101 | client.network.options.CASEMAPPING = 'ascii'; 102 | 103 | expect(client.caseCompare('abcdefghijklmnopqrstuvwxyz', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ')).to.be.true; 104 | expect(client.caseCompare('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')).to.be.true; 105 | expect(client.caseCompare('Àtest[]^\\{}~|#&', 'ÀTEST[]^\\{}~|#&')).to.be.true; 106 | expect(client.caseCompare('ÀTEST[]^\\{}~|#&', 'Àtest[]^\\{}~|#&')).to.be.true; 107 | expect(client.caseCompare('ПРИВЕТ, как дела? 👋', 'ПРИВЕТ, как дела? 👋')).to.be.true; 108 | expect(client.caseCompare('#HELLO1', '#HELLO2')).to.be.false; 109 | expect(client.caseCompare('#HELLO', '#HELLO2')).to.be.false; 110 | expect(client.caseCompare('#HELLO', '#HELL')).to.be.false; 111 | expect(client.caseCompare('#HELL', '#HELLO')).to.be.false; 112 | expect(client.caseCompare('#HELLOZ', '#HELLOZ')).to.be.true; 113 | expect(client.caseCompare('#HELLOZ[', '#HELLOZ{')).to.be.false; 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /test/commands/handlers/misc.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals describe, it */ 4 | /* eslint-disable no-unused-expressions */ 5 | const chai = require('chai'); 6 | const expect = chai.expect; 7 | const mocks = require('../../mocks'); 8 | const sinonChai = require('sinon-chai'); 9 | const misc = require('../../../src/commands/handlers/misc'); 10 | const IrcCommand = require('../../../src/commands/command'); 11 | 12 | chai.use(sinonChai); 13 | 14 | describe('src/commands/handlers/misc.js', function() { 15 | describe('PING handler', function() { 16 | const mock = mocks.IrcCommandHandler([misc]); 17 | const cmd = new IrcCommand('PING', { 18 | params: ['example.com'], 19 | tags: { 20 | time: '2021-06-29T16:42:00Z', 21 | } 22 | }); 23 | mock.handlers.PING(cmd, mock.spies); 24 | 25 | it('should respond with the appropriate PONG message', function() { 26 | expect(mock.spies.connection.write).to.have.been.calledOnce; 27 | expect(mock.spies.connection.write).to.have.been.calledWith('PONG example.com'); 28 | }); 29 | 30 | it('should emit the appropriate PING event', function() { 31 | expect(mock.spies.emit).to.have.been.calledOnce; 32 | expect(mock.spies.emit).to.have.been.calledWith('ping', { 33 | message: undefined, 34 | time: 1624984920000, 35 | tags: { 36 | time: '2021-06-29T16:42:00Z' 37 | } 38 | }); 39 | }); 40 | }); 41 | 42 | describe('PONG handler', function() { 43 | it('should emit the appropriate PONG event', function() { 44 | const mock = mocks.IrcCommandHandler([misc]); 45 | const cmd = new IrcCommand('PONG', { 46 | params: ['one.example.com', 'two.example.com'], 47 | tags: { 48 | time: '2011-10-10T14:48:00Z', 49 | } 50 | }); 51 | mock.handlers.PONG(cmd, mock.spies); 52 | expect(mock.spies.network.addServerTimeOffset).to.have.been.calledOnce; 53 | expect(mock.spies.emit).to.have.been.calledOnce; 54 | expect(mock.spies.emit).to.have.been.calledWith('pong', { 55 | message: 'two.example.com', 56 | time: 1318258080000, 57 | tags: { 58 | time: '2011-10-10T14:48:00Z' 59 | } 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /test/helper.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals describe, it */ 4 | const chai = require('chai'); 5 | const Helper = require('../src/helpers'); 6 | const expect = chai.expect; 7 | 8 | chai.use(require('chai-subset')); 9 | 10 | describe('src/irclineparser.js', function() { 11 | describe('mask parsing', function() { 12 | it('should recognize when just passed a nick', function() { 13 | const msgObj = Helper.parseMask('something'); 14 | 15 | expect(msgObj).to.containSubset({ 16 | nick: 'something', 17 | }); 18 | }); 19 | 20 | it('should recognize when just passed a host', function() { 21 | const msgObj = Helper.parseMask('irc.server.com'); 22 | 23 | expect(msgObj).to.containSubset({ 24 | nick: '', 25 | host: 'irc.server.com', 26 | }); 27 | }); 28 | 29 | it('should recognize when just passed a nick and user', function() { 30 | const msgObj = Helper.parseMask('something!something'); 31 | 32 | expect(msgObj).to.containSubset({ 33 | nick: 'something', 34 | user: 'something', 35 | }); 36 | }); 37 | 38 | it('should recognize when just passed a nick and host', function() { 39 | const msgObj = Helper.parseMask('something@something'); 40 | 41 | expect(msgObj).to.containSubset({ 42 | host: 'something', 43 | nick: 'something', 44 | }); 45 | }); 46 | 47 | it('should recognize when just passed a nick, user, and host', function() { 48 | const msgObj = Helper.parseMask('something!something@something'); 49 | 50 | expect(msgObj).to.containSubset({ 51 | nick: 'something', 52 | host: 'something', 53 | user: 'something', 54 | }); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /test/messagetags.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* globals describe, it */ 3 | const chai = require('chai'); 4 | const MessageTags = require('../src/messagetags'); 5 | const expect = chai.expect; 6 | const assert = chai.assert; 7 | 8 | chai.use(require('chai-subset')); 9 | 10 | describe('src/messagetags.js', function() { 11 | describe('value encoding', function() { 12 | it('should decode characters to correct strings', function() { 13 | const plain = "Some people use IRC; others don't \\o/ Note: Use IRC\r\n"; 14 | const encoded = "Some\\speople\\suse\\sIRC\\:\\sothers\\sdon't\\s\\\\o/\\sNote:\\sUse\\sIRC\\r\\n"; 15 | 16 | assert.equal(MessageTags.decodeValue(encoded), plain); 17 | }); 18 | 19 | it('should encode characters to correct strings', function() { 20 | const plain = "Some people use IRC; others don't \\o/ Note: Use IRC\r\n"; 21 | const encoded = "Some\\speople\\suse\\sIRC\\:\\sothers\\sdon't\\s\\\\o/\\sNote:\\sUse\\sIRC\\r\\n"; 22 | 23 | assert.equal(MessageTags.encodeValue(plain), encoded); 24 | }); 25 | }); 26 | 27 | describe('encoding', function() { 28 | it('should encode from an object', function() { 29 | const plain = { 30 | foo: 'bar', 31 | tls: true, 32 | string: 'with space', 33 | }; 34 | const encoded = 'foo=bar;tls;string=with\\sspace'; 35 | 36 | assert.equal(MessageTags.encode(plain), encoded); 37 | }); 38 | 39 | it('should allow changing separator to space', function() { 40 | const plain = { 41 | foo: 'bar', 42 | tls: true, 43 | string: 'with space', 44 | }; 45 | const encoded = 'foo=bar tls string=with\\sspace'; 46 | 47 | assert.equal(MessageTags.encode(plain, ' '), encoded); 48 | }); 49 | 50 | it('should return an empty string', function() { 51 | assert.equal(MessageTags.encode({}), ''); 52 | }); 53 | }); 54 | 55 | describe('parsing', function() { 56 | it('should decode tag string into an object', function() { 57 | const plain = 'foo=bar;baz;'; 58 | const tags = MessageTags.decode(plain); 59 | expect(tags).to.containSubset({ 60 | foo: 'bar', 61 | baz: '', 62 | }); 63 | }); 64 | 65 | it('should decode a tag string into an object with correct characters', function() { 66 | const plain = 'foo=bar;baz;name=prawn\\ssalad'; 67 | const tags = MessageTags.decode(plain); 68 | expect(tags).to.deep.equal({ 69 | foo: 'bar', 70 | baz: '', 71 | name: 'prawn salad', 72 | }); 73 | }); 74 | 75 | it('should handle equals signs in the tag value', function() { 76 | const plain = 'foo=bar=baz;hello;world=monde'; 77 | const tags = MessageTags.decode(plain); 78 | expect(tags).to.deep.equal({ 79 | foo: 'bar=baz', 80 | hello: '', 81 | world: 'monde', 82 | }); 83 | }); 84 | 85 | it('should work with duplicate tags', function() { 86 | const plain = 'foo;foo=one;foo=two;foo=lastvalue'; 87 | const tags = MessageTags.decode(plain); 88 | expect(tags).to.deep.equal({ 89 | foo: 'lastvalue', 90 | }); 91 | }); 92 | 93 | it('should work with empty values', function() { 94 | const plain = 'foo;bar=;baz;'; 95 | const tags = MessageTags.decode(plain); 96 | expect(tags).to.deep.equal({ 97 | foo: '', 98 | bar: '', 99 | baz: '', 100 | }); 101 | }); 102 | 103 | it('should handle invalid escapes', function() { 104 | const plain = 'foo=test\\;bar=\\b\\sinvalidescape'; 105 | const tags = MessageTags.decode(plain); 106 | expect(tags).to.deep.equal({ 107 | foo: 'test', 108 | bar: 'b invalidescape', 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/mocks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const _ = require('lodash'); 5 | 6 | module.exports = { 7 | IrcCommandHandler: function(modules) { 8 | const handlers = {}; 9 | modules.map(function(m) { 10 | return m({ 11 | addHandler: function(command, handler) { 12 | handlers[command] = handler; 13 | } 14 | }); 15 | }); 16 | const stubs = { 17 | emit: sinon.stub(), 18 | connection: { 19 | write: sinon.stub() 20 | }, 21 | network: { 22 | addServerTimeOffset: sinon.stub() 23 | }, 24 | }; 25 | const handler = _.mapValues(stubs, function spyify(value) { 26 | if (_.isFunction(value)) { 27 | return sinon.spy(value); 28 | } else if (_.isObject(value)) { 29 | return _.mapValues(value, spyify); 30 | } 31 | }); 32 | return { 33 | handlers: handlers, 34 | stubs: stubs, 35 | spies: handler 36 | }; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /test/networkinfo.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals describe, it */ 4 | const chai = require('chai'); 5 | const assert = chai.assert; 6 | const NetworkInfo = require('../src/networkinfo'); 7 | const IrcCommandHandler = require('../src/commands/handler'); 8 | 9 | function newMockClient() { 10 | const handler = new IrcCommandHandler({ network: new NetworkInfo() }); 11 | return handler; 12 | } 13 | 14 | describe('src/networkinfo.js', function() { 15 | describe('isChannelName', function() { 16 | const names = ['chan', '#chan', '.chan', '%chan', '&#chan', '%#chan']; 17 | 18 | it('should identify names as channels when CHANTYPES is not given', function() { 19 | const client = newMockClient(); 20 | const results = names.map(name => client.network.isChannelName(name)); 21 | assert.deepEqual(results, [false, true, false, false, true, false]); 22 | }); 23 | 24 | it('should identify names as channels when CHANTYPES is standard', function() { 25 | const client = newMockClient(); 26 | client.dispatch({ 27 | command: '005', 28 | params: ['nick', 'CHANTYPES=#&'], 29 | tags: [] 30 | }); 31 | const results = names.map(name => client.network.isChannelName(name)); 32 | assert.deepEqual(results, [false, true, false, false, true, false]); 33 | }); 34 | 35 | it('should identify names as channels when CHANTYPES is non-standard', function() { 36 | const client = newMockClient(); 37 | client.dispatch({ 38 | command: '005', 39 | params: ['nick', 'CHANTYPES=%'], 40 | tags: [] 41 | }); 42 | const results = names.map(name => client.network.isChannelName(name)); 43 | assert.deepEqual(results, [false, false, false, true, false, true]); 44 | }); 45 | 46 | it('should not identify any names as channels when no CHANTYPES are supported', function() { 47 | const client = newMockClient(); 48 | client.dispatch({ 49 | command: '005', 50 | params: ['nick', 'CHANTYPES='], 51 | tags: [] 52 | }); 53 | const results = names.map(name => client.network.isChannelName(name)); 54 | assert.deepEqual(results, [false, false, false, false, false, false]); 55 | }); 56 | }); 57 | 58 | describe('CLIENTTAGDENY Support', function() { 59 | it('should parse CLIENTTAGDENY=a,b,c as a list', function() { 60 | const client = newMockClient(); 61 | client.dispatch({ 62 | command: '005', 63 | params: ['nick', 'CLIENTTAGDENY=a,b,c'], 64 | tags: [] 65 | }); 66 | assert.deepEqual(client.network.options.CLIENTTAGDENY, ['a', 'b', 'c']); 67 | }); 68 | 69 | it('should parse CLIENTTAGDENY=*,-a,-b as a list', function() { 70 | const client = newMockClient(); 71 | client.dispatch({ 72 | command: '005', 73 | params: ['nick', 'CLIENTTAGDENY=*,-a,-b'], 74 | tags: [] 75 | }); 76 | assert.deepEqual(client.network.options.CLIENTTAGDENY, ['*', '-a', '-b']); 77 | }); 78 | 79 | it('should parse CLIENTTAGDENY= as a list', function() { 80 | const client = newMockClient(); 81 | client.dispatch({ 82 | command: '005', 83 | params: ['nick', 'CLIENTTAGDENY='], 84 | tags: [] 85 | }); 86 | assert.isArray(client.network.options.CLIENTTAGDENY); 87 | assert.isEmpty(client.network.options.CLIENTTAGDENY); 88 | }); 89 | 90 | it('should be undefined when no CLIENTTAGDENY', function() { 91 | const client = newMockClient(); 92 | client.dispatch({ 93 | command: '005', 94 | params: ['nick', ''], 95 | tags: [] 96 | }); 97 | assert.isUndefined(client.network.options.CLIENTTAGDENY); 98 | }); 99 | 100 | it('should deny all when no message-tags CAP', function() { 101 | const client = newMockClient(); 102 | client.dispatch({ 103 | command: '005', 104 | params: ['nick', 'CLIENTTAGDENY=*,-a,-b'], 105 | tags: [] 106 | }); 107 | assert.isFalse(client.network.supportsTag('a')); 108 | assert.isFalse(client.network.supportsTag('b')); 109 | }); 110 | 111 | it('should allow all when CLIENTTAGDENY=', function() { 112 | const client = newMockClient(); 113 | client.network.cap.enabled.push('message-tags'); 114 | client.dispatch({ 115 | command: '005', 116 | params: ['nick', 'CLIENTTAGDENY='], 117 | tags: [] 118 | }); 119 | assert.isTrue(client.network.supportsTag('a')); 120 | assert.isTrue(client.network.supportsTag('b')); 121 | }); 122 | 123 | it('should deny all when CLIENTTAGDENY=*', function() { 124 | const client = newMockClient(); 125 | client.network.cap.enabled.push('message-tags'); 126 | client.dispatch({ 127 | command: '005', 128 | params: ['nick', 'CLIENTTAGDENY=*'], 129 | tags: [] 130 | }); 131 | assert.isFalse(client.network.supportsTag('a')); 132 | assert.isFalse(client.network.supportsTag('b')); 133 | }); 134 | 135 | it('should allow a & deny b, c when CLIENTTAGDENY=*,-a', function() { 136 | const client = newMockClient(); 137 | client.network.cap.enabled.push('message-tags'); 138 | client.dispatch({ 139 | command: '005', 140 | params: ['nick', 'CLIENTTAGDENY=*,-a'], 141 | tags: [] 142 | }); 143 | assert.isTrue(client.network.supportsTag('a')); 144 | assert.isFalse(client.network.supportsTag('b')); 145 | }); 146 | 147 | it('should allow a & deny b when CLIENTTAGDENY=b', function() { 148 | const client = newMockClient(); 149 | client.network.cap.enabled.push('message-tags'); 150 | client.dispatch({ 151 | command: '005', 152 | params: ['nick', 'CLIENTTAGDENY=b'], 153 | tags: [] 154 | }); 155 | assert.isTrue(client.network.supportsTag('a')); 156 | assert.isFalse(client.network.supportsTag('b')); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/servertime.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* globals describe, it */ 3 | const chai = require('chai'); 4 | const IrcCommand = require('../src/commands/command'); 5 | const expect = chai.expect; 6 | 7 | describe('src/commands/command.js', function() { 8 | describe('getServerTime parsing', function() { 9 | it('should parse ISO8601 correctly', function() { 10 | const cmd = new IrcCommand('', { 11 | tags: { 12 | time: '2011-10-10T14:48:00Z', 13 | } 14 | }); 15 | 16 | expect(cmd.getServerTime()).to.equal(1318258080000); 17 | }); 18 | 19 | it('should parse unix timestamps', function() { 20 | const cmd = new IrcCommand('', { 21 | tags: { 22 | time: '1318258080', 23 | } 24 | }); 25 | 26 | expect(cmd.getServerTime()).to.equal(1318258080000); 27 | }); 28 | 29 | it('should parse unix timestamps with milliseconds', function() { 30 | const cmd = new IrcCommand('', { 31 | tags: { 32 | time: '1318258080.1234', 33 | } 34 | }); 35 | 36 | expect(cmd.getServerTime()).to.equal(1318258080123); 37 | }); 38 | 39 | it('should return undefined for missing time', function() { 40 | const cmd = new IrcCommand('', { 41 | tags: {} 42 | }); 43 | 44 | expect(cmd.getServerTime()).to.equal(undefined); 45 | }); 46 | 47 | it('should return undefined for empty time', function() { 48 | const cmd = new IrcCommand('', { 49 | tags: { 50 | time: '', 51 | } 52 | }); 53 | 54 | expect(cmd.getServerTime()).to.equal(undefined); 55 | }); 56 | 57 | it('should return undefined for malformed time', function() { 58 | const cmd = new IrcCommand('', { 59 | tags: { 60 | time: 'definetelyNotAtimestamp', 61 | } 62 | }); 63 | 64 | expect(cmd.getServerTime()).to.equal(undefined); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /test/setEncoding.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /* globals describe, it */ 4 | const Connection = require('../src/transports/net'); 5 | const chai = require('chai'); 6 | const assert = chai.assert; 7 | 8 | chai.use(require('chai-subset')); 9 | 10 | describe('src/transports/net.js', function() { 11 | describe('setEncoding', function() { 12 | it('should set encoding', function() { 13 | const conn = new Connection(); 14 | assert.equal(conn.setEncoding('utf8'), true); 15 | assert.equal(conn.encoding, 'utf8'); 16 | assert.equal(conn.setEncoding('ascii'), true); 17 | assert.equal(conn.encoding, 'ascii'); 18 | assert.equal(conn.setEncoding('windows-1252'), true); 19 | assert.equal(conn.encoding, 'windows-1252'); 20 | }); 21 | 22 | it('should not set encoding if ASCII fails', function() { 23 | const conn = new Connection(); 24 | assert.equal(conn.encoding, 'utf8'); 25 | assert.equal(conn.setEncoding('base64'), false); 26 | assert.equal(conn.setEncoding('ucs2'), false); 27 | assert.equal(conn.setEncoding('utf16le'), false); 28 | assert.equal(conn.setEncoding('hex'), false); 29 | assert.equal(conn.setEncoding('utf16be'), false); 30 | assert.equal(conn.setEncoding('utf16'), false); 31 | assert.equal(conn.setEncoding('utf7imap'), false); 32 | assert.equal(conn.encoding, 'utf8'); 33 | }); 34 | 35 | it('should not set encoding if invalid', function() { 36 | const conn = new Connection(); 37 | assert.equal(conn.setEncoding('this encoding totally does not exist'), false); 38 | assert.equal(conn.encoding, 'utf8'); 39 | assert.equal(conn.setEncoding(null), false); 40 | assert.equal(conn.encoding, 'utf8'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/stringToChunks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* globals describe, it */ 3 | const chai = require('chai'); 4 | const { 5 | lineBreak, 6 | WordTooLargeForLineError, 7 | GraphemeTooLargeForLineError, 8 | CodepointTooLargeForLineError, 9 | } = require('../src/linebreak'); 10 | const expect = chai.expect; 11 | 12 | chai.use(require('chai-subset')); 13 | 14 | describe('src/client.js', function() { 15 | describe('lineBreak', function() { 16 | it('should return an array if input fits in a single block', function() { 17 | expect( 18 | [...lineBreak('test', { bytes: 100, allowBreakingWords: true })] 19 | ).to.deep.equal(['test']); 20 | }); 21 | 22 | it('should correctly split complicated emojis', function() { 23 | // full family emoji - 11 characters 24 | const family = '\u{1F468}\u200D\u{1F469}\u200D\u{1F467}\u200D\u{1F466}'; 25 | const plain = `testing emoji splitting ${family}${family}${family} test string ${family}`; 26 | const blocks = [ 27 | 'testing emoji splitting', 28 | family, 29 | family, 30 | family, 31 | 'test string', 32 | family 33 | ]; 34 | 35 | expect( 36 | [...lineBreak(plain, { bytes: 28, allowBreakingWords: true })] 37 | ).to.deep.equal(blocks); 38 | }); 39 | 40 | it('should split even more complicated unicode', function() { 41 | const input = 'foo bar z̶͖̮̜̯̝͈̭̽̅̇̈́̓̐̈́̐́͜ȃ̶̖̹̰̭̘̩͙͎̰̠̳͛̓̕͝͝͝ĺ̶̢̢̢̺̪̯̮̘͕͎̜̮̂̌͊̾̒̈́́̈́̎̌͜͜͝͝g̴̱̟̤̞̤͙̦̗̹̦̠͋̊̈́̈́̓̈́̈́̕ȱ̶̧̡͓̜̥̝͊͜͜͝ 🏳️‍🌈 baz 👩‍👨‍👩‍👧‍👦‍👧‍👧‍👦 ok'; 42 | 43 | const expected = [ 44 | 45 | 'foo bar z̶͖̮̜̯̝͈̭̽̅̇̈́̓̐̈́̐́͜', 'ȃ̶̖̹̰̭̘̩͙͎̰̠̳͛̓̕͝͝͝', 'ĺ̶̢̢̢̺̪̯̮̘͕͎̜̮̂̌͊̾̒̈́́̈́̎̌͜͜͝͝', 'g̴̱̟̤̞̤͙̦̗̹̦̠͋̊̈́̈́̓̈́̈́̕', 'ȱ̶̧̡͓̜̥̝͊͜͜͝ 🏳️‍🌈 baz', '👩‍👨‍👩‍👧‍👦‍👧‍👧‍👦 ok', 46 | ]; 47 | 48 | expect( 49 | [...lineBreak(input, { bytes: 60, allowBreakingWords: true })] 50 | ).to.deep.equal(expected); 51 | }); 52 | 53 | it('should split zalgo text by grapheme cluster', function() { 54 | const zalgo = ['z̸̩̉̿̐͗̾͘', 'a̷̮̭̠͍͐̋̏̈́̓̂̚', 'l̵̼̟̲̘̣͐̀̎̂', 'g̷̡̗̪̘̥͋͂͛́͘͝', 'ö̶̱̤̫̝̬̰́']; 55 | 56 | expect( 57 | [...lineBreak(zalgo.join(''), { bytes: 25, allowBreakingWords: true })] 58 | ).to.deep.equal(zalgo); 59 | }); 60 | 61 | it('should split ascii string', function() { 62 | const plain = 'just a normal string, testing word splitting :)'; 63 | const blocks = [ 64 | 'just a normal', 65 | 'string, testing', 66 | 'word splitting', 67 | ':)' 68 | ]; 69 | 70 | expect( 71 | [...lineBreak(plain, { bytes: 15, allowBreakingWords: true })] 72 | ).to.deep.equal(blocks); 73 | }); 74 | 75 | it('should still split if input is bad in second block', function() { 76 | const plain = 'testasdf \u200d\u200d\u200d\u200d\u200d\u200d\u200d'; 77 | const blocks = [ 78 | 'testasd', 79 | 'f \u200d', 80 | '\u200d\u200d', 81 | '\u200d\u200d', 82 | '\u200d\u200d', 83 | ]; 84 | 85 | expect( 86 | [...lineBreak(plain, { 87 | bytes: 7, 88 | allowBreakingWords: true, 89 | allowBreakingGraphemes: true, 90 | })] 91 | ).to.deep.equal(blocks); 92 | }); 93 | 94 | it('should throw if word splitting is required but not allowed', function() { 95 | const plain = 'hsdfgjkhsdfjgkhsdkjfghsdkj'; 96 | 97 | expect( 98 | () => [...lineBreak(plain, { bytes: 2 })] 99 | ).to.throw(WordTooLargeForLineError); 100 | }); 101 | 102 | it('should throw if grapheme splitting is required but not allowed', function() { 103 | const plain = 'test \u200d\u200d\u200d\u200d\u200d\u200d\u200d'; 104 | 105 | expect( 106 | () => [...lineBreak(plain, { 107 | bytes: 10, 108 | allowBreakingWords: true, 109 | allowBreakingGraphemes: true, 110 | })] 111 | ).to.not.throw(); 112 | 113 | expect( 114 | () => [...lineBreak(plain, { 115 | bytes: 10, 116 | allowBreakingWords: true, 117 | })] 118 | ).to.throw(GraphemeTooLargeForLineError); 119 | }); 120 | 121 | it('should throw if codepoint splitting is required', function() { 122 | const plain = 'test \u200d\u200d\u200d\u200d\u200d\u200d\u200d'; 123 | 124 | expect( 125 | () => [...lineBreak(plain, { 126 | bytes: 1, 127 | allowBreakingWords: true, 128 | allowBreakingGraphemes: true, 129 | })] 130 | ).to.throw(CodepointTooLargeForLineError); 131 | }); 132 | 133 | it('should still split if input is bad', function() { 134 | const plain = '\u200d'.repeat(10); 135 | const blocks = [ 136 | '\u200d\u200d', 137 | '\u200d\u200d', 138 | '\u200d\u200d', 139 | '\u200d\u200d', 140 | '\u200d\u200d' 141 | ]; 142 | 143 | expect([...lineBreak(plain, { 144 | bytes: 6, 145 | allowBreakingWords: true, 146 | allowBreakingGraphemes: true, 147 | })]).to.deep.equal(blocks); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | // webpack v4 2 | const path = require('path'); 3 | const CompressionPlugin = require('compression-webpack-plugin'); 4 | 5 | const shouldCompress = /\.(js|css|html|svg)(\.map)?$/ 6 | 7 | module.exports = { 8 | mode: 'production', 9 | entry: './dist/browser/src', 10 | output: { 11 | path: path.join(path.resolve(__dirname), 'dist', 'browser', 'static'), 12 | filename: 'browser.js', 13 | library: 'irc-framework', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true 16 | }, 17 | module: { 18 | rules: [ ] 19 | }, 20 | resolve: { 21 | fallback: { 22 | // Fallback modules for node internals when building with webpack5 23 | 'stream': require.resolve('stream-browserify'), 24 | 'buffer': require.resolve('buffer/'), 25 | 'util': require.resolve('util/'), 26 | }, 27 | }, 28 | plugins: [ 29 | new CompressionPlugin({ 30 | filename: "[path][base].gz", 31 | algorithm: "gzip", 32 | test: shouldCompress, 33 | }), 34 | new CompressionPlugin({ 35 | filename: "[path][base].br", 36 | algorithm: 'brotliCompress', 37 | test: shouldCompress, 38 | }), 39 | ], 40 | optimization: { 41 | minimize: true 42 | }, 43 | devtool: 'source-map', 44 | performance: { 45 | assetFilter: assetFilename => 46 | !assetFilename.match(/\.map(\.(gz|br))?$/), 47 | 48 | // Prevent warnings about entrypoint and asset size 49 | maxEntrypointSize: 343040, // 335KiB 50 | maxAssetSize: 343040, // 335KiB 51 | }, 52 | }; 53 | --------------------------------------------------------------------------------