diff --git a/docs/clientapi.md b/docs/clientapi.md index 9c6d09be..b3e9c1fa 100644 --- a/docs/clientapi.md +++ b/docs/clientapi.md @@ -12,6 +12,7 @@ new Irc.Client({ version: 'node.js irc-framework', enable_chghost: false, enable_echomessage: false, + enable_multiline: false, auto_reconnect: true, auto_reconnect_max_wait: 300000, auto_reconnect_max_retries: 3, @@ -103,6 +104,14 @@ be emitted as a `labeled response` event. ##### `.notice(target, message [, tags [, options]])` Send a notice to the target, optionally with tags. Accepts the same options as `.say()`. +##### `.sayMultiline(target, lines [, tags])` +##### `.noticeMultiline(target, lines [, tags])` +Send `lines` to `target` as a single +[`draft/multiline`](https://ircv3.net/specs/extensions/multiline) batch when the +network supports the capability. Falls + +Requires `enable_multiline: true`. + ##### `.tagmsg(target, tags)` Send a tagged message without content to the target diff --git a/docs/events.md b/docs/events.md index 892d023e..e0360e31 100644 --- a/docs/events.md +++ b/docs/events.md @@ -390,7 +390,8 @@ Also triggers a **message** event with .type = 'privmsg' message: 'Hello everybody', tags: [], time: 000000000, - account: 'account_name' + account: 'account_name', + multiline: false } ~~~ diff --git a/docs/ircv3.md b/docs/ircv3.md index ece20404..9af04f53 100644 --- a/docs/ircv3.md +++ b/docs/ircv3.md @@ -1,35 +1,50 @@ ### IRCv3 Support #### IRCv3.1 support -* CAP -* sasl -* multi-prefix -* account-notify -* away-notify -* extended-join + +- CAP +- sasl +- multi-prefix +- account-notify +- away-notify +- extended-join #### IRCv3.2 support -* CAP -* account-tag -* batch -* chghost -* echo-message -* invite-notify -* sasl -* server-time -* userhost-in-names -* message-tags -* labeled-response + +- CAP +- account-tag +- batch +- chghost +- echo-message +- invite-notify +- sasl +- server-time +- userhost-in-names +- message-tags +- labeled-response + +#### Drafts + +- draft/multiline #### Extra notes -* chghost + +- chghost 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. -* echo-message +- echo-message 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. -* labeled-response +- labeled-response Automatically enabled when `enable_echomessage` is `true` (requires batch, which is always requested). Pass `{ label: true }` as options to `say()`, `notice()`, or `action()` to attach a label. The server's response will trigger a `labeled response` event on the client with the matching label. See [events.md](events.md) for the event format. + +- draft/multiline + + Only enabled if the client `enable_multiline` option is `true` and the + network advertises the capability. Inbound batches are concatenated and emitted + as a single `privmsg` / `notice` event with `multiline: true` set, using the + prefix and tags from the `BATCH` start command. The parsed `max-bytes` / + `max-lines` limits are reachable via `client.network.multilineLimits()`. diff --git a/src/client.js b/src/client.js index 56cbb5da..096dcc9e 100644 --- a/src/client.js +++ b/src/client.js @@ -17,6 +17,13 @@ const User = require('./user'); const Channel = require('./channel'); const { lineBreak } = require('./linebreak'); const MessageTags = require('./messagetags'); +const { encode: encodeUTF8 } = require('isomorphic-textencoder'); + +let batch_reftag_counter = 0; +function generateBatchReftag() { + batch_reftag_counter = (batch_reftag_counter + 1) % 0xffffffff; + return Date.now().toString(36) + batch_reftag_counter.toString(36); +} let default_transport = null; @@ -549,6 +556,89 @@ module.exports = class IrcClient extends EventEmitter { return this.sendMessage('NOTICE', target, message, tags, options); } + sendMultiline(commandName, target, lines, tags) { + const limits = this.network.multilineLimits(); + + if (!limits) { + // as a fallback, split into individual lines + for (let i = 0; i < lines.length; i++) { + this.sendMessage(commandName, target, lines[i], tags); + } + return; + } + + const frames = []; + let totalBytes = 0; + const maxBytes = this.options.message_max_length; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (i > 0) { + // \n separator between logical lines counts toward max-bytes per spec + totalBytes += 1; + } + + if (line === '') { + frames.push({ message: '', concat: false }); + continue; + } + + const blocks = [...lineBreak(line, { + bytes: maxBytes, + allowBreakingWords: true, + allowBreakingGraphemes: true, + })]; + + blocks.forEach((block, blockIdx) => { + frames.push({ + message: block, + concat: blockIdx > 0, + }); + totalBytes += encodeUTF8(block).byteLength; + }); + } + + if (totalBytes > limits.maxBytes) { + const err = new Error('Multiline batch exceeds max-bytes limit'); + err.code = 'MULTILINE_MAX_BYTES'; + err.limit = limits.maxBytes; + throw err; + } + if (limits.maxLines !== null && frames.length > limits.maxLines) { + const err = new Error('Multiline batch exceeds max-lines limit'); + err.code = 'MULTILINE_MAX_LINES'; + err.limit = limits.maxLines; + throw err; + } + + const reftag = generateBatchReftag(); + + const startMsg = new IrcMessage('BATCH', '+' + reftag, 'draft/multiline', target); + if (tags && Object.keys(tags).length) { + startMsg.tags = tags; + } + this.raw(startMsg); + + frames.forEach((frame) => { + const msg = new IrcMessage(commandName, target, frame.message); + msg.tags = { batch: reftag }; + if (frame.concat) { + msg.tags['draft/multiline-concat'] = true; + } + this.raw(msg); + }); + + this.raw('BATCH', '-' + reftag); + } + + sayMultiline(target, lines, tags) { + return this.sendMultiline('PRIVMSG', target, lines, tags); + } + + noticeMultiline(target, lines, tags) { + return this.sendMultiline('NOTICE', target, lines, tags); + } + tagmsg(target, tags = {}) { const msg = new IrcMessage('TAGMSG', target); msg.tags = tags; diff --git a/src/commands/handlers/messaging.js b/src/commands/handlers/messaging.js index ae5b71bc..1d80594b 100644 --- a/src/commands/handlers/messaging.js +++ b/src/commands/handlers/messaging.js @@ -46,6 +46,7 @@ const handlers = { account: command.getTag('account'), batch: command.batch, label: command.label || command.getTag('label') || undefined, + multiline: !!command.multiline, }); } }, @@ -115,6 +116,7 @@ const handlers = { account: command.getTag('account'), batch: command.batch, label: command.label || command.getTag('label') || undefined, + multiline: !!command.multiline, }); } }, diff --git a/src/commands/handlers/misc.js b/src/commands/handlers/misc.js index f6c7b537..fde8454d 100644 --- a/src/commands/handlers/misc.js +++ b/src/commands/handlers/misc.js @@ -6,6 +6,7 @@ const _ = { map: require('lodash/map'), }; const Helpers = require('../../helpers'); +const IrcCommand = require('../command'); const handlers = { RPL_LISTSTART: function(command, handler) { @@ -338,6 +339,15 @@ const handlers = { cache.label = label; } + // https://ircv3.net/specs/extensions/multiline#message-tags-spec + if (cache.type === 'draft/multiline') { + cache.tags = command.tags; + cache.prefix = command.prefix; + cache.nick = command.nick; + cache.ident = command.ident; + cache.hostname = command.hostname; + } + return; } @@ -372,17 +382,22 @@ const handlers = { handler.emit('batch start', emit_obj); handler.emit('batch start ' + emit_obj.type, emit_obj); - emit_obj.commands.forEach((c) => { - c.batch = { - id: batch_id, - type: cache.type, - params: cache.params - }; - if (emit_obj.label) { - c.label = emit_obj.label; - } - handler.executeCommand(c); - }); + if (cache.type === 'draft/multiline') { + handleMultilineBatch(emit_obj, cache, handler); + } else { + emit_obj.commands.forEach((c) => { + c.batch = { + id: batch_id, + type: cache.type, + params: cache.params + }; + if (emit_obj.label) { + c.label = emit_obj.label; + } + handler.executeCommand(c); + }); + } + handler.emit('batch end', emit_obj); handler.emit('batch end ' + emit_obj.type, emit_obj); } @@ -417,3 +432,76 @@ function getChanListCache(handler) { return cache; } + +// https://ircv3.net/specs/extensions/multiline +function handleMultilineBatch(emit_obj, cache, handler) { + const lines = emit_obj.commands; + + if (lines.length === 0) { + return; + } + + // Use same command (PRIVMSG or NOTICE) and target for all lines + const first = lines[0]; + const lineCommand = first.command; + if (lineCommand !== 'PRIVMSG' && lineCommand !== 'NOTICE') { + return; + } + + const target = first.params[0]; + + let allBlank = true; + for (let i = 0; i < lines.length; i++) { + const c = lines[i]; + if (c.command !== lineCommand) { + return; + } + if (c.params[0] !== target) { + return; + } + const msg = c.params[c.params.length - 1]; + const isBlank = msg === ''; + if (!isBlank) { + allBlank = false; + } + if (isBlank && c.tags && c.tags['draft/multiline-concat'] !== undefined) { + // https://ircv3.net/specs/extensions/multiline#batch-types + // "Clients MUST NOT send blank lines with the draft/multiline-concat tag."" + return; + } + } + + if (allBlank) { + return; + } + + let combined = ''; + for (let i = 0; i < lines.length; i++) { + const c = lines[i]; + const msg = c.params[c.params.length - 1]; + if (i === 0) { + combined = msg; + } else if (c.tags && c.tags['draft/multiline-concat'] !== undefined) { + combined += msg; + } else { + combined += '\n' + msg; + } + } + + const tmp = new IrcCommand(lineCommand, { + params: [target, combined], + tags: cache.tags || Object.create(null), + prefix: cache.prefix !== undefined ? cache.prefix : first.prefix, + nick: cache.nick !== undefined ? cache.nick : first.nick, + ident: cache.ident !== undefined ? cache.ident : first.ident, + hostname: cache.hostname !== undefined ? cache.hostname : first.hostname, + }); + tmp.batch = { + id: emit_obj.id, + type: emit_obj.type, + params: emit_obj.params, + }; + tmp.multiline = true; + + handler.executeCommand(tmp); +} diff --git a/src/commands/handlers/registration.js b/src/commands/handlers/registration.js index 7875bc84..350e0bd5 100644 --- a/src/commands/handlers/registration.js +++ b/src/commands/handlers/registration.js @@ -174,6 +174,9 @@ const handlers = { // useful alongside echo-message for correlating sent messages want.push('labeled-response'); } + if (handler.connection.options.enable_multiline) { + want.push('draft/multiline'); + } want = _.uniq(want.concat(handler.request_extra_caps)); diff --git a/src/networkinfo.js b/src/networkinfo.js index e759e144..62fa7444 100644 --- a/src/networkinfo.js +++ b/src/networkinfo.js @@ -80,6 +80,41 @@ function NetworkInfo() { return this.options[support_name.toUpperCase()]; }; + this.multilineLimits = function multilineLimits() { + if (!this.cap.isEnabled('draft/multiline')) { + return null; + } + + const value = this.cap.available.get('draft/multiline'); + if (typeof value !== 'string' || value === '') { + return null; + } + + const limits = { maxBytes: 0, maxLines: null }; + value.split(',').forEach((token) => { + const sep = token.indexOf('='); + if (sep === -1) { + return; + } + const key = token.substr(0, sep); + const num = parseInt(token.substr(sep + 1), 10); + if (Number.isNaN(num)) { + return; + } + if (key === 'max-bytes') { + limits.maxBytes = num; + } else if (key === 'max-lines') { + limits.maxLines = num; + } + }); + + if (limits.maxBytes <= 0) { + return null; + } + + return limits; + }; + this.supportsTag = function supportsTag(tag_name) { if (!this.cap.isEnabled('message-tags')) { return false; diff --git a/test/multiline.test.js b/test/multiline.test.js new file mode 100644 index 00000000..e922b2ea --- /dev/null +++ b/test/multiline.test.js @@ -0,0 +1,244 @@ +'use strict'; +/* globals describe, it */ +const chai = require('chai'); +const sinon = require('sinon'); +const IrcClient = require('../src/client'); +const NetworkInfo = require('../src/networkinfo'); + +const expect = chai.expect; + +function newClient() { + const client = new IrcClient({}); + client._applyDefaultOptions(client.options); + // Don't try to actually connect + client.connection.write = sinon.stub(); + return client; +} + +function dispatch(client, msg) { + client.command_handler.dispatch(Object.assign({ + prefix: 'alice!u@h', + nick: 'alice', + ident: 'u', + hostname: 'h', + tags: Object.create(null), + }, msg)); +} + +describe('draft/multiline', function() { + describe('NetworkInfo.multilineLimits', function() { + it('returns null when CAP not enabled', function() { + const net = new NetworkInfo(); + net.cap.available.set('draft/multiline', 'max-bytes=4096,max-lines=24'); + expect(net.multilineLimits()).to.equal(null); + }); + + it('parses max-bytes and max-lines when CAP enabled', function() { + const net = new NetworkInfo(); + net.cap.enabled.push('draft/multiline'); + net.cap.available.set('draft/multiline', 'max-bytes=4096,max-lines=24'); + expect(net.multilineLimits()).to.deep.equal({ maxBytes: 4096, maxLines: 24 }); + }); + + it('treats max-lines as null when not provided', function() { + const net = new NetworkInfo(); + net.cap.enabled.push('draft/multiline'); + net.cap.available.set('draft/multiline', 'max-bytes=4096'); + expect(net.multilineLimits()).to.deep.equal({ maxBytes: 4096, maxLines: null }); + }); + + it('returns null when max-bytes is missing', function() { + const net = new NetworkInfo(); + net.cap.enabled.push('draft/multiline'); + net.cap.available.set('draft/multiline', 'max-lines=24'); + expect(net.multilineLimits()).to.equal(null); + }); + }); + + describe('inbound BATCH draft/multiline', function() { + it('concatenates lines with newline separators by default', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + client.on('privmsg', onPrivmsg); + + dispatch(client, { command: 'BATCH', params: ['+b1', 'draft/multiline', '#chan'], tags: { msgid: 'xyz' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'hello'], tags: { batch: 'b1' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'world'], tags: { batch: 'b1' } }); + dispatch(client, { command: 'BATCH', params: ['-b1'], tags: {} }); + + sinon.assert.calledOnce(onPrivmsg); + const arg = onPrivmsg.firstCall.args[0]; + expect(arg.message).to.equal('hello\nworld'); + expect(arg.multiline).to.equal(true); + expect(arg.target).to.equal('#chan'); + expect(arg.nick).to.equal('alice'); + expect(arg.batch.id).to.equal('b1'); + expect(arg.batch.type).to.equal('draft/multiline'); + // Tags taken from the BATCH start command + expect(arg.tags.msgid).to.equal('xyz'); + }); + + it('joins via draft/multiline-concat with no separator', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + client.on('privmsg', onPrivmsg); + + dispatch(client, { command: 'BATCH', params: ['+b2', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'how is '], tags: { batch: 'b2' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'everyone?'], tags: { batch: 'b2', 'draft/multiline-concat': true } }); + dispatch(client, { command: 'BATCH', params: ['-b2'], tags: {} }); + + sinon.assert.calledOnce(onPrivmsg); + expect(onPrivmsg.firstCall.args[0].message).to.equal('how is everyone?'); + }); + + it('handles a mix of line-feeds and concat tags per the spec example', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + client.on('privmsg', onPrivmsg); + + dispatch(client, { command: 'BATCH', params: ['+b3', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'hello'], tags: { batch: 'b3' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', ''], tags: { batch: 'b3' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'how is '], tags: { batch: 'b3' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'everyone?'], tags: { batch: 'b3', 'draft/multiline-concat': true } }); + dispatch(client, { command: 'BATCH', params: ['-b3'], tags: {} }); + + sinon.assert.calledOnce(onPrivmsg); + expect(onPrivmsg.firstCall.args[0].message).to.equal('hello\n\nhow is everyone?'); + }); + + it('drops a malformed batch with a blank concat line', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + client.on('privmsg', onPrivmsg); + + dispatch(client, { command: 'BATCH', params: ['+b4', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'hello'], tags: { batch: 'b4' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', ''], tags: { batch: 'b4', 'draft/multiline-concat': true } }); + dispatch(client, { command: 'BATCH', params: ['-b4'], tags: {} }); + + sinon.assert.notCalled(onPrivmsg); + }); + + it('drops a batch consisting entirely of blank lines', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + client.on('privmsg', onPrivmsg); + + dispatch(client, { command: 'BATCH', params: ['+b5', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', ''], tags: { batch: 'b5' } }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', ''], tags: { batch: 'b5' } }); + dispatch(client, { command: 'BATCH', params: ['-b5'], tags: {} }); + + sinon.assert.notCalled(onPrivmsg); + }); + + it('drops a batch mixing PRIVMSG and NOTICE', function() { + const client = newClient(); + const onPrivmsg = sinon.spy(); + const onNotice = sinon.spy(); + client.on('privmsg', onPrivmsg); + client.on('notice', onNotice); + + dispatch(client, { command: 'BATCH', params: ['+b6', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'PRIVMSG', params: ['#chan', 'a'], tags: { batch: 'b6' } }); + dispatch(client, { command: 'NOTICE', params: ['#chan', 'b'], tags: { batch: 'b6' } }); + dispatch(client, { command: 'BATCH', params: ['-b6'], tags: {} }); + + sinon.assert.notCalled(onPrivmsg); + sinon.assert.notCalled(onNotice); + }); + + it('emits as a NOTICE when the batch contains NOTICEs', function() { + const client = newClient(); + const onNotice = sinon.spy(); + client.on('notice', onNotice); + + dispatch(client, { command: 'BATCH', params: ['+b7', 'draft/multiline', '#chan'], tags: {} }); + dispatch(client, { command: 'NOTICE', params: ['#chan', 'one'], tags: { batch: 'b7' } }); + dispatch(client, { command: 'NOTICE', params: ['#chan', 'two'], tags: { batch: 'b7' } }); + dispatch(client, { command: 'BATCH', params: ['-b7'], tags: {} }); + + sinon.assert.calledOnce(onNotice); + expect(onNotice.firstCall.args[0].message).to.equal('one\ntwo'); + expect(onNotice.firstCall.args[0].multiline).to.equal(true); + }); + }); + + describe('CAP negotiation', function() { + it('does not request draft/multiline by default', function() { + const client = new IrcClient({}); + client._applyDefaultOptions(client.options); + expect(client.options.enable_multiline).to.not.equal(true); + }); + }); + + describe('outbound sayMultiline', function() { + function clientWithMultilineCap(maxBytes, maxLines) { + const client = newClient(); + client.network.cap.enabled.push('draft/multiline'); + const value = `max-bytes=${maxBytes}` + (maxLines !== undefined ? `,max-lines=${maxLines}` : ''); + client.network.cap.available.set('draft/multiline', value); + return client; + } + + it('falls back to per-line PRIVMSGs when CAP is not enabled', function() { + const client = newClient(); + client.sayMultiline('#chan', ['one', 'two']); + + const writes = client.connection.write.getCalls().map(c => c.args[0]); + expect(writes).to.deep.equal([ + 'PRIVMSG #chan one', + 'PRIVMSG #chan two', + ]); + }); + + it('emits BATCH frames when CAP is enabled', function() { + const client = clientWithMultilineCap(4096, 24); + client.sayMultiline('#chan', ['hello', 'world']); + + const writes = client.connection.write.getCalls().map(c => c.args[0]); + expect(writes.length).to.equal(4); + // BATCH + draft/multiline #chan + expect(writes[0]).to.match(/^BATCH \+\S+ draft\/multiline #chan$/); + const reftag = writes[0].split(' ')[1].slice(1); + expect(writes[1]).to.equal(`@batch=${reftag} PRIVMSG #chan hello`); + expect(writes[2]).to.equal(`@batch=${reftag} PRIVMSG #chan world`); + expect(writes[3]).to.equal(`BATCH -${reftag}`); + }); + + it('splits long logical lines and adds draft/multiline-concat to continuations', function() { + const client = clientWithMultilineCap(4096, 24); + client.options.message_max_length = 10; + client.sayMultiline('#c', ['the quick brown fox jumps']); + + const writes = client.connection.write.getCalls().map(c => c.args[0]); + // First write: BATCH start, last: BATCH end. Middle: continuation lines. + expect(writes[0]).to.match(/^BATCH \+/); + expect(writes[writes.length - 1]).to.match(/^BATCH -/); + const continuations = writes.slice(2, -1); + // Every continuation block (after the first) carries the concat tag + for (const w of continuations) { + expect(w).to.match(/draft\/multiline-concat/); + } + }); + + it('throws when the combined byte count exceeds max-bytes', function() { + const client = clientWithMultilineCap(10); + expect(() => client.sayMultiline('#c', ['this is way too long'])).to.throw(/max-bytes/); + }); + + it('throws when the line count exceeds max-lines', function() { + const client = clientWithMultilineCap(4096, 2); + expect(() => client.sayMultiline('#c', ['a', 'b', 'c'])).to.throw(/max-lines/); + }); + + it('exposes noticeMultiline using NOTICE', function() { + const client = clientWithMultilineCap(4096, 24); + client.noticeMultiline('#c', ['hi']); + const writes = client.connection.write.getCalls().map(c => c.args[0]); + expect(writes[1]).to.match(/NOTICE #c hi$/); + }); + }); +});