diff --git a/changelog.d/105.feature b/changelog.d/105.feature new file mode 100644 index 00000000..746d17f2 --- /dev/null +++ b/changelog.d/105.feature @@ -0,0 +1 @@ +Add support for [message tags](https://ircv3.net/specs/extensions/message-tags). \ No newline at end of file diff --git a/src/capabilities.ts b/src/capabilities.ts index bc297227..db5bcca9 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -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(), @@ -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'); diff --git a/src/irc.ts b/src/irc.ts index 0eb9e38a..7fda235a 100644 --- a/src/irc.ts +++ b/src/irc.ts @@ -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; @@ -197,7 +198,7 @@ export class Client extends (EventEmitter as unknown as new () => TypedEmitter TypedEmitter TypedEmitter; prefix?: string; server?: string; nick?: string; @@ -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 = /^(?:[^ ]+) +(?.+)/; +const IRC_LINE_MATCH_WITH_TAGS_REGEX = /^(?@[^ ]+ )?(?:[^ ]+)? +(?.+)/; +const IRC_PREFIX_REGEX = /^(?[_a-zA-Z0-9\[\]\\`^{}|-]*)?(!(?[^@]+))?@?(?.*)$/ + +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|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; @@ -60,8 +133,8 @@ 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'); } @@ -69,7 +142,7 @@ export function parseMessage(line: string, stripColors: boolean): Message { trailing = match[2]; } else { - middle = line; + middle = parameters; } if (middle.length) {message.args = middle.split(/ +/);} diff --git a/test/data/fixtures.json b/test/data/fixtures.json index b6fc871a..8825fac2 100644 --- a/test/data/fixtures.json +++ b/test/data/fixtures.json @@ -101,6 +101,9 @@ "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", @@ -108,10 +111,12 @@ "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", @@ -119,18 +124,80 @@ "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": [ diff --git a/test/test-parse-line.js b/test/test-parse-line.js index 12d0db36..26bbed1a 100644 --- a/test/test-parse-line.js +++ b/test/test-parse-line.js @@ -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' ); });