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 5 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).
4 changes: 4 additions & 0 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ export class IrcCapabilities extends (EventEmitter as new () => IrcCapabilitiesE
return this.userCapabilites.ready;
}

public isSupported(capability: string) {
return this.userCapabilites.caps.has(capability);
}

public get supportsSasl() {
if (!this.serverCapabilites.ready) {
throw Error('Server response has not arrived yet');
Expand Down
11 changes: 8 additions & 3 deletions src/irc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,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,7 +292,9 @@ export class Client extends (EventEmitter as unknown as new () => TypedEmitter<C
}

private onCapsList() {
const requiredCapabilites = [];
const requiredCapabilites = [
'message-tags',
];
if (this.opt.sasl) {
requiredCapabilites.push('sasl');
}
Expand Down Expand Up @@ -1360,7 +1362,10 @@ 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('message-tags'),
});
try {
this.emit('raw', message);
}
Expand Down
107 changes: 89 additions & 18 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,114 @@ 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_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(/^:([^ ]+) +/);
let match = line.match(opts.supportsMessageTags ? IRC_LINE_MATCH_WITH_TAGS_REGEX : IRC_LINE_MATCH_REGEX);
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;
if (!prefix) {
throw Error('No prefix on message');
}
message.prefix = prefix;
const prefixMatch = message.prefix.match(/^([_a-zA-Z0-9\[\]\\`^{}|-]*)(!([^@]+)@(.*))?$/);

if (prefixMatch) {
message.nick = prefixMatch[1];
message.user = prefixMatch[3];
message.host = prefixMatch[4];
}
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(/\\(s|\\|r|n|:)/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;
}
}),
]
}
) 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 +130,17 @@ 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) {
console.log('Egg!');
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
67 changes: 59 additions & 8 deletions test/data/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,36 +101,87 @@
"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\\\\ :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; \\" ] ],
"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"
}

},
"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