diff --git a/docs/clientapi.md b/docs/clientapi.md index cd0d4c86..9c6d09be 100644 --- a/docs/clientapi.md +++ b/docs/clientapi.md @@ -95,11 +95,13 @@ Ping the IRC server to show you're still alive. ##### `.changeNick(nick)` Attempt to change the clients nick on the network -##### `.say(target, message [, tags])` -Send a message to the target, optionally with tags. +##### `.say(target, message [, tags [, options]])` +Send a message to the target, optionally with tags. Pass `{ label: true }` as options +to attach a labeled-response label (if the cap is enabled). The server's response will +be emitted as a `labeled response` event. -##### `.notice(target, message [, tags])` -Send a notice to the target, optionally with tags. +##### `.notice(target, message [, tags [, options]])` +Send a notice to the target, optionally with tags. Accepts the same options as `.say()`. ##### `.tagmsg(target, tags)` Send a tagged message without content to the target @@ -122,8 +124,10 @@ Send a CTCP request to target with any number of parameters. ##### `.ctcpResponse(target, type [, paramN])` Send a CTCP response to target with any number of parameters. -##### `.action(target, message [, tags])` -Send an action message (typically /me) to a target, optionally with tags. +##### `.action(target, message [, tags [, options]])` +Send an action message (typically /me) to a target, optionally with tags. Pass +`{ label: true }` as options to attach a labeled-response label (if the cap is +enabled). The server's response will be emitted as a `labeled response` event. ##### `.whois(nick [, cb])` Receive information about a user on the network if they exist. Optionally calls diff --git a/docs/events.md b/docs/events.md index caec0b75..892d023e 100644 --- a/docs/events.md +++ b/docs/events.md @@ -697,6 +697,26 @@ A `batch end ` event is also triggered. ~~~ +**labeled response** + +Emitted when a labeled-response is received from the server, correlating a response +with a previously sent labeled command. The `label` property matches the label string +returned by `say()`, `action()`, etc. when `{ label: true }` was passed. + +`type` will be one of `'ack'` (command produced no response), `'single'` (single message +response), or `'batch'` (multi-message batched response). +~~~javascript +// ACK (no response needed) +{ label: 'L1', type: 'ack' } + +// Single message response (eg. ERR_NOSUCHNICK) +{ label: 'L2', type: 'single', command: IrcCommand } + +// Batched response (eg. WHOIS) +{ label: 'L3', type: 'batch', batchType: 'labeled-response', commands: [...] } +~~~ + + **cap ls**, **cap ack**, **cap nak**, **cap list**, **cap new**, **cap del** Triggered for each `CAP` command, lists the sent capabilities list. diff --git a/docs/ircv3.md b/docs/ircv3.md index ebcb2121..ece20404 100644 --- a/docs/ircv3.md +++ b/docs/ircv3.md @@ -19,6 +19,7 @@ * server-time * userhost-in-names * message-tags +* labeled-response #### Extra notes * chghost @@ -28,4 +29,7 @@ * 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. - 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 + +* 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. diff --git a/src/client.js b/src/client.js index 628f4f76..9adb8480 100644 --- a/src/client.js +++ b/src/client.js @@ -73,6 +73,10 @@ module.exports = class IrcClient extends EventEmitter { createStructure() { const client = this; + // Labeled-response tracking + client._labelCounter = 0; + client._pendingLabels = new Map(); + // Provides middleware hooks for either raw IRC commands or the easier to use parsed commands client.raw_middleware = new MiddlewareHandler(); client.parsed_middleware = new MiddlewareHandler(); @@ -121,6 +125,10 @@ module.exports = class IrcClient extends EventEmitter { client.network.cap.enabled = []; client.network.cap.available.clear(); + // Clear any pending labeled-response entries from the previous connection + client._pendingLabels.clear(); + client._labelCounter = 0; + client.command_handler.resetCache(); }); @@ -419,6 +427,42 @@ module.exports = class IrcClient extends EventEmitter { } } + /** + * ==== labeled-response support ==== + */ + _nextLabel() { + this._labelCounter = (this._labelCounter + 1) % 1000000; + return 'L' + this._labelCounter; + } + + _applyLabel(tags) { + if (!this.network.cap.isEnabled('labeled-response')) { + return null; + } + + const label = this._nextLabel(); + tags.label = label; + this._pendingLabels.set(label, { time: Date.now() }); + return label; + } + + _resolvePendingLabel(label, data) { + const pending = this._pendingLabels.get(label); + if (pending) { + if (pending.timer) { + clearTimeout(pending.timer); + } + this._pendingLabels.delete(label); + } + + const event = Object.assign({ label: label }, data); + this.emit('labeled response', event); + } + + /** + * ==== end labeled-response support ==== + */ + rawString(input) { let args; @@ -451,7 +495,11 @@ module.exports = class IrcClient extends EventEmitter { this.raw('NICK', nick); } - sendMessage(commandName, target, message, tags) { + sendMessage(commandName, target, message, tags, options) { + const label_requested = options && options.label; + let label_applied = false; + const use_tags = label_requested || (tags && Object.keys(tags).length); + const lines = message .split(/\r\n|\n|\r/) .filter(i => i); @@ -469,9 +517,16 @@ module.exports = class IrcClient extends EventEmitter { ]; blocks.forEach(block => { - if (tags && Object.keys(tags).length) { + if (use_tags) { const msg = new IrcMessage(commandName, target, block); - msg.tags = tags; + msg.tags = Object.assign(Object.create(null), tags || {}); + + // Servers respond once per label + if (label_requested && !label_applied) { + this._applyLabel(msg.tags); + label_applied = true; + } + this.raw(msg); } else { this.raw(commandName, target, block); @@ -480,12 +535,12 @@ module.exports = class IrcClient extends EventEmitter { }); } - say(target, message, tags) { - return this.sendMessage('PRIVMSG', target, message, tags); + say(target, message, tags, options) { + return this.sendMessage('PRIVMSG', target, message, tags, options); } - notice(target, message, tags) { - return this.sendMessage('NOTICE', target, message, tags); + notice(target, message, tags, options) { + return this.sendMessage('NOTICE', target, message, tags, options); } tagmsg(target, tags = {}) { @@ -662,8 +717,11 @@ module.exports = class IrcClient extends EventEmitter { ); } - action(target, message, tags) { + action(target, message, tags, options) { const that = this; + const label_requested = options && options.label; + let label_applied = false; + const has_tags = tags && Object.keys(tags).length; // Maximum length of target + message we can send to the IRC server is 500 characters // but we need to leave extra room for the sender prefix so the entire message can @@ -677,10 +735,15 @@ module.exports = class IrcClient extends EventEmitter { const blocks = [...lineBreak(message, { bytes: blockLength, allowBreakingWords: true, allowBreakingGraphemes: true })]; blocks.forEach(function(block) { - const ctcpBody = String.fromCharCode(1) + commandName + ' ' + block + String.fromCharCode(1); - if (tags && Object.keys(tags).length) { + const should_label = label_requested && !label_applied; + if (has_tags || should_label) { + const ctcpBody = String.fromCharCode(1) + commandName + ' ' + block + String.fromCharCode(1); const msg = new IrcMessage('PRIVMSG', target, ctcpBody); - msg.tags = tags; + msg.tags = has_tags ? Object.assign(Object.create(null), tags) : Object.create(null); + if (should_label) { + that._applyLabel(msg.tags); + label_applied = true; + } that.raw(msg); } else { that.ctcpRequest(target, commandName, block); diff --git a/src/commands/handler.js b/src/commands/handler.js index 2cb46aff..db357a2c 100644 --- a/src/commands/handler.js +++ b/src/commands/handler.js @@ -49,6 +49,18 @@ module.exports = class IrcCommandHandler extends EventEmitter { // has already sent the end batch command. } } else { + // Check for labeled-response on non-batched single-message responses. + // Batched labeled responses are handled in the BATCH end handler. + // ACK is handled in its own handler. + const label = irc_command.getTag('label'); + if (label && irc_command.command !== 'BATCH' && irc_command.command !== 'ACK') { + irc_command.label = label; + this.client._resolvePendingLabel(label, { + type: 'single', + command: irc_command, + }); + } + this.executeCommand(irc_command); } } diff --git a/src/commands/handlers/messaging.js b/src/commands/handlers/messaging.js index afc967c9..ae5b71bc 100644 --- a/src/commands/handlers/messaging.js +++ b/src/commands/handlers/messaging.js @@ -44,7 +44,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + label: command.label || command.getTag('label') || undefined, }); } }, @@ -76,7 +77,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + label: command.label || command.getTag('label') || undefined, }); } else if (ctcp_command === 'VERSION' && handler.connection.options.version) { handler.connection.write(util.format( @@ -111,7 +113,8 @@ const handlers = { tags: command.tags, time: time, account: command.getTag('account'), - batch: command.batch + batch: command.batch, + label: command.label || command.getTag('label') || undefined, }); } }, diff --git a/src/commands/handlers/misc.js b/src/commands/handlers/misc.js index f85e5c88..f6c7b537 100644 --- a/src/commands/handlers/misc.js +++ b/src/commands/handlers/misc.js @@ -306,6 +306,15 @@ const handlers = { NOTE: standardReply, + ACK: function(command, handler) { + const label = command.getTag('label'); + if (label) { + handler.client._resolvePendingLabel(label, { + type: 'ack', + }); + } + }, + BATCH: function(command, handler) { const batch_start = command.params[0].substr(0, 1) === '+'; const batch_id = command.params[0].substr(1); @@ -321,6 +330,14 @@ const handlers = { cache.type = command.params[1]; cache.params = command.params.slice(2); + // If the batch start has a label tag, store it for resolution + // when the batch ends (per labeled-response spec, the label is + // on the BATCH + opener) + const label = command.getTag('label'); + if (label) { + cache.label = label; + } + return; } @@ -336,13 +353,23 @@ const handlers = { id: batch_id, type: cache.type, params: cache.params, - commands: cache.commands + commands: cache.commands, + label: cache.label || null, }; // Destroy the cache object before executing each command. If one // errors out then we don't have the cache object stuck in memory. cache.destroy(); + // Resolve the pending labeled-response if this batch was labeled + if (emit_obj.label) { + handler.client._resolvePendingLabel(emit_obj.label, { + type: 'batch', + batchType: emit_obj.type, + commands: emit_obj.commands, + }); + } + handler.emit('batch start', emit_obj); handler.emit('batch start ' + emit_obj.type, emit_obj); emit_obj.commands.forEach((c) => { @@ -351,6 +378,9 @@ const handlers = { type: cache.type, params: cache.params }; + if (emit_obj.label) { + c.label = emit_obj.label; + } handler.executeCommand(c); }); handler.emit('batch end', emit_obj); diff --git a/src/commands/handlers/registration.js b/src/commands/handlers/registration.js index 7d9daf97..7875bc84 100644 --- a/src/commands/handlers/registration.js +++ b/src/commands/handlers/registration.js @@ -169,6 +169,11 @@ const handlers = { if (handler.connection.options.enable_standardreplies) { want.push('standard-replies'); } + if (handler.connection.options.enable_echomessage) { + // labeled-response requires batch and is most + // useful alongside echo-message for correlating sent messages + want.push('labeled-response'); + } want = _.uniq(want.concat(handler.request_extra_caps));