discord-command-parser
Version:
Basic parsing for messages received with discord.js
230 lines (196 loc) • 8.2 kB
text/typescript
// https://npmjs.com/package/discord-command-parser
// https://github.com/campbellbrendene/discord-command-parser
// Licensed under the MIT license. See "LICENSE" in the root of this project.
export const version = "1.5.3";
/**
* The base message type with all the properties needed by the library.
*/
export interface BasicMessage {
content: string;
author: {
bot: boolean;
};
}
export interface SuccessfulParsedMessage<T extends BasicMessage> {
readonly success: true;
/** The prefix that the user provided. */
readonly prefix: string;
/** The name of the command issued. */
readonly command: string;
/** Everything after the command name. */
readonly body: string;
/** An array of command arguments. You might also consider using `reader`. */
readonly arguments: string[];
/** A wrapper around arguments with helper methods such as `getUserID()`. */
readonly reader: MessageArgumentReader;
/** The message. */
readonly message: T;
}
export interface FailedParsedMessage<T extends BasicMessage> {
readonly success: false;
/** A description of why the parsing failed. */
readonly error: string;
/** The message. */
readonly message: T;
}
export type ParsedMessage<T extends BasicMessage> = FailedParsedMessage<T> | SuccessfulParsedMessage<T>;
export interface ParserOptions {
allowBots: boolean;
allowSpaceBeforeCommand: boolean;
ignorePrefixCase: boolean;
}
function getArguments(body: string): string[] {
const args: string[] = [];
let str = body.trim();
while (str.length) {
let arg: string;
if (str.startsWith('"') && str.indexOf('"', 1) > 0) {
arg = str.slice(1, str.indexOf('"', 1));
str = str.slice(str.indexOf('"', 1) + 1);
} else if (str.startsWith("'") && str.indexOf("'", 1) > 0) {
arg = str.slice(1, str.indexOf("'", 1));
str = str.slice(str.indexOf("'", 1) + 1);
} else if (str.startsWith("```") && str.indexOf("```", 3) > 0) {
arg = str.slice(3, str.indexOf("```", 3));
str = str.slice(str.indexOf("```", 3) + 3);
} else {
arg = str.split(/\s+/g)[0].trim();
str = str.slice(arg.length);
}
args.push(arg.trim());
str = str.trim();
}
return args;
}
type Validator<T> = (value: T) => boolean;
export class MessageArgumentReader {
args: string[];
body: string;
_index: number;
constructor(args: string[], body: string) {
this.args = args.slice();
this.body = body;
this._index = 0;
}
/** Returns the next argument (or null if exhausted) and advances the index (unless `peek` is `true`). */
getString(peek: boolean = false, v?: Validator<string>): string | null {
if (this._index >= this.args.length) return null;
const value = this.args[peek ? this._index : this._index++];
return v ? (v(value) ? value : null) : value;
}
/** Gets all the remaining text and advances the index to the end (unless `peek` is `true`). */
getRemaining(peek: boolean = false): string | null {
if (this._index >= this.args.length) return null;
let remaining = this.body.trim();
for (let i = 0; i < this._index; i++) {
if (remaining.startsWith('"') && remaining.charAt(this.args[i].length + 1) === '"') {
remaining = remaining.slice(this.args[i].length + 2).trim();
} else if (remaining.startsWith("'") && remaining.charAt(this.args[i].length + 1) === "'") {
remaining = remaining.slice(this.args[i].length + 2).trim();
} else if (remaining.startsWith("```") && remaining.slice(this.args[i].length + 3).startsWith("```")) {
remaining = remaining.slice(this.args[i].length + 6).trim();
} else {
remaining = remaining.slice(this.args[i].length).trim();
}
}
if (!peek) this.seek(Infinity);
return remaining;
}
/**
* Advances the index (unless `peek` is `true`) and tries to parse an integer
* using `Number.parseInt`, returning `null` if NaN.
*/
getInt(peek: boolean = false, v?: Validator<number>): number | null {
const str = this.getString(peek);
if (str === null) return null;
const parsed = Number.isNaN(str) || !/^-?\d+$/g.test(str) ? null : Number.parseInt(str);
if (parsed === null || parsed > Number.MAX_SAFE_INTEGER || parsed < Number.MIN_SAFE_INTEGER) return null;
return v ? (v(parsed) ? parsed : null) : parsed;
}
/**
* Advances the index (unless `peek` is `true`) and tries to parse a floating-point number
* (with a maximum guaranteed precision of 2 decimal places)
* using `Number.parseFloat`, returning `null` if NaN or out of range.
*/
getFloat(peek: boolean = false, v?: Validator<number>): number | null {
const str = this.getString(peek);
if (str === null) return null;
const parsed = Number.isNaN(str) || !/^-?\d*(\.\d+)?$/.test(str) ? null : Number.parseFloat(str);
if (parsed === null || parsed > 703_687_441_77_664 || parsed < -703_687_441_77_664) return null;
return v ? (v(parsed) ? parsed : null) : parsed;
}
/** Advances the index (unless `peek` is `true`) and tries to parse a valid user ID or mention and returns the ID, if found. */
getUserID(peek: boolean = false, v?: Validator<string>): string | null {
const str = this.getString(peek);
if (str === null) return null;
if (/^\d{17,19}$/.test(str)) return str;
const match = str.match(/^\<@!?(\d{17,19})\>$/);
if (match && match[1]) return v ? (v(match[1]) ? match[1] : null) : match[1];
return null;
}
/** Advances the index (unless `peek` is `true`) and tries to parse a valid role ID or mention and returns the ID, if found. */
getRoleID(peek: boolean = false, v?: Validator<string>): string | null {
const str = this.getString(peek);
if (str === null) return null;
if (/^\d{17,19}$/.test(str)) return str;
const match = str.match(/^\<@&?(\d{17,19})\>$/);
if (match && match[1]) return v ? (v(match[1]) ? match[1] : null) : match[1];
return null;
}
/** Advances the index (unless `peek` is `true`) and tries to parse a valid channel ID or mention and returns the ID, if found. */
getChannelID(peek: boolean = false, v?: Validator<string>): string | null {
const str = this.getString(peek);
if (str === null) return null;
if (/^\d{17,19}$/.test(str)) return str;
const match = str.match(/^\<#(\d{17,19})\>$/);
if (match && match[1]) return v ? (v(match[1]) ? match[1] : null) : match[1];
return null;
}
/** Safely increments or decrements the index. Use this for skipping arguments. */
seek(amount: number = 1): this {
this._index += amount;
if (this._index < 0) this._index = 0;
if (this._index > this.args.length) this._index = this.args.length;
return this;
}
}
export function parse<T extends BasicMessage>(
message: T,
prefix: string | string[],
options: Partial<ParserOptions> = {}
): ParsedMessage<T> {
function fail(error: string): FailedParsedMessage<T> {
return { success: false, error, message };
}
const prefixes = Array.isArray(prefix) ? [...prefix] : [prefix];
if (message.author.bot && !options.allowBots) return fail("Message sent by a bot account");
if (!message.content) return fail("Message body empty");
let matchedPrefix: string | null = null;
for (const p of prefixes) {
if (
(options.ignorePrefixCase && message.content.toLowerCase().startsWith(p.toLowerCase())) ||
message.content.startsWith(p)
) {
matchedPrefix = p;
break;
}
}
if (!matchedPrefix) return fail("Message does not start with prefix");
let remaining = message.content.slice(matchedPrefix.length);
if (!remaining) return fail("No body after prefix");
if (!options.allowSpaceBeforeCommand && /^\s/.test(remaining)) return fail("Space before command name");
remaining = remaining.trim();
const command = remaining.match(/^[^\s]+/i)?.[0];
if (!command) return fail("Could not match a command");
remaining = remaining.slice(command.length).trim();
const args = getArguments(remaining);
return {
success: true,
message,
prefix: matchedPrefix,
arguments: args,
reader: new MessageArgumentReader(args, remaining),
body: remaining,
command,
};
}