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
16 changes: 10 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,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
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.
85 changes: 74 additions & 11 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,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;

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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 = {}) {
Expand Down Expand Up @@ -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
Expand All @@ -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);
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 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