Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/clientapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -122,8 +124,9 @@ 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)`
Send an action message (typically /me) to a target.
##### `.action(target, message [, options])`
Send an action message (typically /me) to a target. Pass `{ label: true }` as options
to attach a labeled-response label.

##### `.whois(nick [, cb])`
Receive information about a user on the network if they exist. Optionally calls
Expand Down
20 changes: 20 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,26 @@ A `batch end <type>` 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.
Expand Down
6 changes: 5 additions & 1 deletion docs/ircv3.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* server-time
* userhost-in-names
* message-tags
* labeled-response

#### Extra notes
* chghost
Expand All @@ -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.
90 changes: 81 additions & 9 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -419,6 +427,48 @@ module.exports = class IrcClient extends EventEmitter {
}
}

/**
* Generate the next label value for labeled-response.
* Returns a short opaque string, max 64 bytes per spec.
*/
_nextLabel() {
this._labelCounter = (this._labelCounter + 1) % 1000000;
return 'L' + this._labelCounter;
}

/**
* If labeled-response is enabled, generate a label, attach it to the
* tags object, and register it as pending. Returns the label string,
* or null if the cap is not enabled.
*/
_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;
}

/**
* Resolve a pending labeled-response.
* Called by the command handler when a labeled response arrives.
*/
_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);
}

rawString(input) {
let args;

Expand Down Expand Up @@ -451,7 +501,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);
Expand All @@ -469,9 +523,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 || {});

// Only label the first block — the server responds once per label
if (label_requested && !label_applied) {
this._applyLabel(msg.tags);
label_applied = true;
}

this.raw(msg);
} else {
this.raw(commandName, target, block);
Expand All @@ -480,12 +541,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 = {}) {
Expand Down Expand Up @@ -662,8 +723,10 @@ module.exports = class IrcClient extends EventEmitter {
);
}

action(target, message) {
action(target, message, options) {
const that = this;
const label_requested = options && options.label;
let label_applied = false;

// 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
Expand All @@ -677,7 +740,16 @@ module.exports = class IrcClient extends EventEmitter {
const blocks = [...lineBreak(message, { bytes: blockLength, allowBreakingWords: true, allowBreakingGraphemes: true })];

blocks.forEach(function(block) {
that.ctcpRequest(target, commandName, block);
if (label_requested && !label_applied) {
const ctcpMsg = String.fromCharCode(1) + commandName + ' ' + block + String.fromCharCode(1);
const msg = new IrcMessage('PRIVMSG', target, ctcpMsg);
msg.tags = Object.create(null);
that._applyLabel(msg.tags);
label_applied = true;
that.raw(msg);
} else {
that.ctcpRequest(target, commandName, block);
}
});

return blocks;
Expand Down
12 changes: 12 additions & 0 deletions src/commands/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 command 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);
}
}
Expand Down
9 changes: 6 additions & 3 deletions src/commands/handlers/messaging.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
},
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
});
}
},
Expand Down
32 changes: 31 additions & 1 deletion src/commands/handlers/misc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}

Expand All @@ -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) => {
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/commands/handlers/registration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Loading