Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for message tags #105

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions changelog.d/105.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for [message tags](https://ircv3.net/specs/extensions/message-tags).
32 changes: 32 additions & 0 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ import EventEmitter from "events";
import { Message } from "./parse_message";
import TypedEmitter from "typed-emitter";

export enum IrcCapability {
/**
* https://ircv3.net/specs/extensions/account-tag
*/
AccountTag = "account-tag",
/**
* https://ircv3.net/specs/extensions/server-time
*/
ServerTime = "server-time",
/**
* https://ircv3.net/specs/extensions/batch
*/
Batch = "batch",
/**
* https://ircv3.net/specs/extensions/message-tags
*/
MessageTags = "message-tags",
/**
* https://ircv3.net/specs/extensions/sasl-3.2
*/
Sasl = "sasl"
}

class Capabilities {
constructor(
public readonly caps = new Set<string>(),
Expand Down Expand Up @@ -67,6 +90,15 @@ export class IrcCapabilities extends (EventEmitter as new () => IrcCapabilitiesE
return this.userCapabilites.ready;
}

/**
* Is at least one of the given capabilities supported.
* @param capability A named capability string.
* @returns True if any of the capabilities are supported, false if none of them are.
*/
public isSupported(...capabilities: IrcCapability[]) {
return capabilities.some(capability => this.userCapabilites.caps.has(capability.toString()));
}

public get supportsSasl() {
if (!this.serverCapabilites.ready) {
throw Error('Server response has not arrived yet');
Expand Down
21 changes: 17 additions & 4 deletions src/irc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import splitLongLines from './splitLines';
import TypedEmitter from "typed-emitter";
import { ClientEvents, CtcpEventIndex, JoinEventIndex, MessageEventIndex, PartEventIndex } from './events';
import { DefaultIrcSupported, IrcClientState, IrcInMemoryState, WhoisResponse } from './state';
import { IrcCapability } from './capabilities';

const lineDelimiter = new RegExp('\r\n|\r|\n');
const MIN_DELAY_MS = 33;
Expand Down Expand Up @@ -197,7 +198,7 @@ export class Client extends (EventEmitter as unknown as new () => TypedEmitter<C
}

constructor (
private server: string, requestedNick: string, opt: IrcClientOpts, existingState?: IrcClientState,
private server: string, requestedNick: string, opt: IrcClientOpts = {}, existingState?: IrcClientState,
public conn?: IrcConnection
) {
super();
Expand Down Expand Up @@ -292,9 +293,13 @@ export class Client extends (EventEmitter as unknown as new () => TypedEmitter<C
}

private onCapsList() {
const requiredCapabilites = [];
const requiredCapabilites = [
IrcCapability.ServerTime,
IrcCapability.AccountTag,
IrcCapability.MessageTags,
];
if (this.opt.sasl) {
requiredCapabilites.push('sasl');
requiredCapabilites.push(IrcCapability.Sasl);
}

if (requiredCapabilites.length === 0) {
Expand Down Expand Up @@ -1360,7 +1365,15 @@ export class Client extends (EventEmitter as unknown as new () => TypedEmitter<C
if (!line.length) {
return;
}
const message = parseMessage(line, this.opt.stripColors);
const message = parseMessage(line, {
stripColors: this.opt.stripColors,
supportsMessageTags: this.state.capabilities.isSupported(
IrcCapability.AccountTag,
IrcCapability.Batch,
IrcCapability.MessageTags,
IrcCapability.ServerTime,
),
});
try {
this.emit('raw', message);
}
Expand Down
111 changes: 92 additions & 19 deletions src/parse_message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CommandType, replyCodes } from './codes';
import { stripColorsAndStyle } from './colors';

export interface Message {
tags?: Map<string, string|undefined>;
prefix?: string;
server?: string;
nick?: string;
Expand All @@ -13,45 +14,117 @@ export interface Message {
commandType: CommandType;
}

interface ParserOptions {
supportsMessageTags: boolean;
/**
* @param stripColors If true, strip IRC colors.
*/
stripColors: boolean;
}

const IRC_LINE_MATCH_REGEX = /^(?<prefix>:[^ ]+) +(?<content>.+)/;
const IRC_LINE_MATCH_WITH_TAGS_REGEX = /^(?<tags>@[^ ]+ )?(?<prefix>:[^ ]+)? +(?<content>.+)/;
const IRC_PREFIX_REGEX = /^(?<nick>[_a-zA-Z0-9\[\]\\`^{}|-]*)?(!(?<user>[^@]+))?@?(?<host>.*)$/

const IRC_COMMAND_REGEX = /^([^ ]+) */;


/**
* parseMessage(line, stripColors)
*
* takes a raw "line" from the IRC server and turns it into an object with
* useful keys
* @param line Raw message from IRC server.
* @param stripColors If true, strip IRC colors.
* @param opts Additional options for parsing.
* For legacy reasons this can be a boolean which maps to the `stripColors` propety.
* @return A parsed message object.
*/
export function parseMessage(line: string, stripColors: boolean): Message {
export function parseMessage(line: string, opts: Partial<ParserOptions>|boolean = false): Message {
if (typeof opts === "boolean") {
opts = {
stripColors: opts,
}
}

const message: Message = {
args: [],
commandType: 'normal',
};
if (stripColors) {

if (opts.stripColors) {
line = stripColorsAndStyle(line);
}

// Parse prefix
let match = line.match(/^:([^ ]+) +/);
// Parse prefix and tags
let match = line.match(opts.supportsMessageTags ? IRC_LINE_MATCH_WITH_TAGS_REGEX : IRC_LINE_MATCH_REGEX);

// Assume content is the full line unless we pull a prefix and/or tags out.
let content = line;

if (match) {
message.prefix = match[1];
line = line.replace(/^:[^ ]+ +/, '');
match = message.prefix.match(/^([_a-zA-Z0-9\[\]\\`^{}|-]*)(!([^@]+)@(.*))?$/);
if (match) {
message.nick = match[1];
message.user = match[3];
message.host = match[4];
const { prefix, tags, content: ctnt } = match.groups || {};
content = ctnt;
message.prefix = prefix?.substring(1);
const prefixMatch = message.prefix?.match(IRC_PREFIX_REGEX);

console.log("PREFIX:", prefixMatch, message.prefix);

if (prefixMatch?.groups) {
message.nick = prefixMatch.groups.nick;
message.user = prefixMatch.groups.user;
message.host = prefixMatch.groups.host;
}
else {
message.server = message.prefix;
}

// Parse the message tags
if (tags) {
message.tags = new Map(
// Strip @
tags.substring(1).trim().split(';').map(
tag => {
const parts = tag.split('=');
return [
parts.splice(0, 1)[0],
parts.join('=').replace(/\\./g, char => {
// https://ircv3.net/specs/extensions/message-tags#escaping-values
switch (char) {
case "\\s":
return " ";
case "\\r":
return "\r";
case "\\n":
return "\n";
case "\\\\":
return '\\';
case "\\:":
return ';';
default:
return char[1];
}
}),
]
}
) as Array<[string, string|undefined]>
);
}
}
else {
// Still allowed, it might just be a command
}

// Parse command
match = line.match(/^([^ ]+) */);
message.command = match?.[1];
message.rawCommand = match?.[1];
line = line.replace(/^[^ ]+ +/, '');
match = content.match(IRC_COMMAND_REGEX);

if (!match?.[1]) {
throw Error('Could not parse command');
}

message.command = match[1];
message.rawCommand = match[1];

const parameters = content.substring(message.rawCommand.length).trim();
if (message.rawCommand && replyCodes[message.rawCommand]) {
message.command = replyCodes[message.rawCommand].name;
message.commandType = replyCodes[message.rawCommand].type;
Expand All @@ -60,16 +133,16 @@ export function parseMessage(line: string, stripColors: boolean): Message {
let middle, trailing;

// Parse parameters
if (line.search(/^:| +:/) !== -1) {
match = line.match(/(.*?)(?:^:| +:)(.*)/);
if (parameters.search(/^:| +:/) !== -1) {
match = parameters.match(/(.*?)(?:^:| +:)(.*)/);
if (!match) {
throw Error('Invalid format, could not parse parameters');
}
middle = match[1].trimEnd();
trailing = match[2];
}
else {
middle = line;
middle = parameters;
}

if (middle.length) {message.args = middle.split(/ +/);}
Expand Down
83 changes: 75 additions & 8 deletions test/data/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,36 +101,103 @@
"args": ["#channel", "so : colons: :are :: not a problem ::::"]
},
":nick!user@host PRIVMSG #channel :\u000314,01\u001fneither are colors or styles\u001f\u0003": {
"opts": {
"stripColors": true
},
"prefix": "nick!user@host",
"nick": "nick",
"user": "user",
"host": "host",
"command": "PRIVMSG",
"rawCommand": "PRIVMSG",
"commandType": "normal",
"args": ["#channel", "neither are colors or styles"],
"stripColors": true
"args": ["#channel", "neither are colors or styles"]
},
":nick!user@host PRIVMSG #channel :\u000314,01\u001fwe can leave styles and colors alone if desired\u001f\u0003": {
"opts": {
"stripColors": false
},
"prefix": "nick!user@host",
"nick": "nick",
"user": "user",
"host": "host",
"command": "PRIVMSG",
"rawCommand": "PRIVMSG",
"commandType": "normal",
"args": ["#channel", "\u000314,01\u001fwe can leave styles and colors alone if desired\u001f\u0003"],
"stripColors": false
"args": ["#channel", "\u000314,01\u001fwe can leave styles and colors alone if desired\u001f\u0003"]
},
":pratchett.freenode.net 324 nodebot #ubuntu +CLcntjf 5:10 #ubuntu-unregged": {
"prefix": "pratchett.freenode.net",
"server": "pratchett.freenode.net",
"@msgid=63E1033A051D4B41B1AB1FA3CF4B243E :nick!user@host PRIVMSG #channel :Hello!": {
"opts": {
"supportsMessageTags": true
},
"prefix": "nick!user@host",
"nick": "nick",
"user": "user",
"host": "host",
"command": "PRIVMSG",
"rawCommand": "PRIVMSG",
"commandType": "normal",
"tags": [ [ "msgid", "63E1033A051D4B41B1AB1FA3CF4B243E" ] ],
"args": ["#channel", "Hello!"]
},
"@+example=raw+:=,escaped\\:\\s\\b\\\\ :nick!user@example.com PRIVMSG #channel :Message": {
"opts": {
"supportsMessageTags": true
},
"user": "user",
"nick": "nick",
"prefix": "nick!user@example.com",
"host": "example.com",
"command": "PRIVMSG",
"rawCommand": "PRIVMSG",
"commandType": "normal",
"tags": [ [ "+example", "raw+:=,escaped; b\\" ] ],
"args": ["#channel", "Message"]
},
"@label=123;msgid=abc;+example-client-tag=example-value :nick!user@example.com TAGMSG #channel": {
"opts": {
"supportsMessageTags": true
},
"prefix": "nick!user@example.com",
"nick": "nick",
"user": "user",
"host": "example.com",
"command": "TAGMSG",
"rawCommand": "TAGMSG",
"commandType": "normal",
"tags":[ [ "label", "123" ], [ "msgid", "abc" ], [ "+example-client-tag", "example-value" ] ],
"args": ["#channel"]
},
":host.foo.bar 324 nodebot #ubuntu +CLcntjf 5:10 #ubuntu-unregged": {
"prefix": "host.foo.bar",
"server": "host.foo.bar",
"command": "rpl_channelmodeis",
"rawCommand": "324",
"commandType": "reply",
"args": ["nodebot", "#ubuntu", "+CLcntjf", "5:10", "#ubuntu-unregged"]
},
"PING mynick": {
"args": [ "mynick" ],
"commandType": "normal",
"command": "PING",
"rawCommand": "PING"
},
"PRIVMSG #test :Hello nodebot!": {
"command": "PRIVMSG",
"rawCommand": "PRIVMSG",
"commandType": "normal",
"args": ["#test", "Hello nodebot!"]
},
"@label=123;msgid=abc;+example-client-tag=example-value TAGMSG #channel": {
"opts": {
"supportsMessageTags": true
},
"command": "TAGMSG",
"rawCommand": "TAGMSG",
"commandType": "normal",
"tags":[ [ "label", "123" ], [ "msgid", "abc" ], [ "+example-client-tag", "example-value" ] ],
"args": ["#channel"]
}

},
"433-before-001": {
"sent": [
Expand Down
14 changes: 9 additions & 5 deletions test/test-parse-line.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ test('irc.parseMessage', function(t) {
const checks = testHelpers.getFixtures('parse-line');

Object.keys(checks).forEach(function(line) {
let stripColors = false;
if (checks[line].hasOwnProperty('stripColors')) {
stripColors = checks[line].stripColors;
delete checks[line].stripColors;
let opts = {};
if (checks[line].opts) {
opts = checks[line].opts;
delete checks[line].opts;
}
const message = parseMessage(line, opts);
t.deepEqual(
{
...message,
...(message.tags && {tags: [...message.tags.entries()]}),
},
checks[line],
parseMessage(line, stripColors),
line + ' parses correctly'
);
});
Expand Down