Skip to content

Commit

Permalink
feat: introduce command v2
Browse files Browse the repository at this point in the history
  • Loading branch information
virtual-designer committed Nov 26, 2023
1 parent 430b511 commit 12a1c44
Show file tree
Hide file tree
Showing 6 changed files with 733 additions and 2 deletions.
2 changes: 1 addition & 1 deletion config/schema/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
"format": "date-time"
}
],
"default": "2023-11-23T15:02:18.294Z"
"default": "2023-11-24T13:56:35.814Z"
}
},
"additionalProperties": false,
Expand Down
279 changes: 279 additions & 0 deletions src/core/CommandArgumentParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import CommandArgumentParserInterface, {
ArgumentType,
ParseOptions,
ParseResult,
ParserJump,
ParsingState,
ValidationErrorType
} from "./CommandArgumentParserInterface";
import { Awaitable, Client, GuildBasedChannel, GuildMember, Role, SnowflakeUtil, User } from "discord.js";
import { logWarn } from "../utils/logger";
import { stringToTimeInterval } from "../utils/datetime";

class ArgumentParseError extends Error {
constructor(message: string, public readonly type: ValidationErrorType) {
super(`[${type}]: ${message}`);
}
}

export default class CommandArgumentParser implements CommandArgumentParserInterface {
protected readonly parsers: Record<ArgumentType, Extract<keyof CommandArgumentParser, `parse${string}Type`>> = {
[ArgumentType.String]: "parseStringType",
[ArgumentType.Snowflake]: "parseSnowflakeType",
[ArgumentType.StringRest]: "parseStringRestType",
[ArgumentType.Channel]: "parseEntityType",
[ArgumentType.User]: "parseEntityType",
[ArgumentType.Role]: "parseEntityType",
[ArgumentType.Member]: "parseEntityType",
[ArgumentType.Float]: "parseNumericType",
[ArgumentType.Integer]: "parseNumericType",
[ArgumentType.Number]: "parseNumericType",
[ArgumentType.Link]: "parseLinkType",
[ArgumentType.TimeInterval]: "parseTimeIntervalType",
};

constructor(protected readonly client: Client) {}

parseTimeIntervalType(state: ParsingState): Awaitable<ParseResult<number>> {
const { result, error } = stringToTimeInterval(state.currentArg!, {
milliseconds: state.rule.time?.unit === 'ms'
});

if (error) {
throw new ArgumentParseError(`Error occurred while parsing time interval: ${error}`, "time:invalid");
}

return {
result
};
}

parseLinkType(state: ParsingState): Awaitable<ParseResult<string | URL>> {
try {
const url = new URL(state.currentArg!);

return {
result: state.rule.link?.urlObject ? url : state.currentArg!
};
}
catch (error) {
throw new ArgumentParseError("Invalid URL", "url:invalid");
}
}

parseNumericType(state: ParsingState): Awaitable<ParseResult<number>> {
const number = state.type === ArgumentType.Float || state.currentArg!.includes('.') ? parseFloat(state.currentArg!) : parseInt(state.currentArg!);

if (isNaN(number)) {
throw new ArgumentParseError("Invalid numeric value", "number:invalid");
}

const max = state.rule.number?.max;
const min = state.rule.number?.min;

if (min !== undefined && number > min) {
throw new ArgumentParseError("Numeric value is less than the minimum limit", "number:range:min");
}
else if (max !== undefined && number > max) {
throw new ArgumentParseError("Numeric value exceeded the maximum limit", "number:range:max");
}

return {
result: number
};
}

async parseEntityType(state: ParsingState): Promise<ParseResult<GuildBasedChannel | User | Role | GuildMember | null>> {
let id = state.currentArg!;

switch (state.type) {
case ArgumentType.Role:
id = id.startsWith('<@&') && id.endsWith('>') ? id.substring(3, id.length - 1) : id;
break;

case ArgumentType.Member:
case ArgumentType.User:
id = id.startsWith('<@') && id.endsWith('>') ? id.substring(id.includes('!') ? 3 : 2, id.length - 1) : id;
break;

case ArgumentType.Channel:
id = id.startsWith('<#') && id.endsWith('>') ? id.substring(2, id.length - 1) : id;
break;

default:
logWarn("parseEntityType logic error: used as unsupported argument type handler");
}

try {
const entity = await (
state.type === ArgumentType.Channel ?
state.parseOptions.message?.guild?.channels.fetch(id) :
state.type === ArgumentType.Member ?
state.parseOptions.message?.guild?.members.fetch(id) :
state.type === ArgumentType.Role ?
state.parseOptions.message?.guild?.roles.fetch(id) :
state.type === ArgumentType.User ?
this.client.users.fetch(id) :
null
);

if (!entity) {
throw new Error();
}

return {
result: entity
};
}
catch (error) {
if (state.rule.entity?.allowNull) {
return {
result: null
};
}

throw new ArgumentParseError("Failed to fetch entity", "entity:null");
}
}

parseStringType(state: ParsingState): Awaitable<ParseResult<string | null>> {
this.validateStringType(state);

return {
result: state.currentArg == '' ? null : state.currentArg
};
}

parseSnowflakeType(state: ParsingState): Awaitable<ParseResult<string>> {
try {
SnowflakeUtil.decode(state.currentArg!);
}
catch (error) {
throw new ArgumentParseError("The snowflake argument is invalid", "snowflake:invalid");
}

return {
result: state.currentArg
};
}

parseStringRestType(state: ParsingState): Awaitable<ParseResult<string>> {
this.validateStringType(state);

let string = state.parseOptions.input
.trim()
.substring(state.parseOptions.prefix.length)
.trim();

for (let i = 0; i < Object.keys(state.parsedArgs).length + 1; i++) {
string = string.trim().substring(state.argv[i].length);
}

string = string.trim();

return {
result: string,
jump: ParserJump.Break
};
}

private validateStringType(state: ParsingState, prefix: 'string' | 'string:rest' = 'string') {
if ((state.rule.string?.notEmpty || !state.rule.optional) && !state.currentArg?.trim() && prefix === 'string')
throw new ArgumentParseError("The string must not be empty", !state.rule.optional ? `required` : `${prefix}:empty`);
else if (state.rule.string?.minLength !== undefined && (state.currentArg?.length ?? 0) < state.rule.string.minLength)
throw new ArgumentParseError("The string is too short", `${prefix}:length:min`);
else if (state.rule.string?.maxLength !== undefined && (state.currentArg?.length ?? 0) > state.rule.string.maxLength)
throw new ArgumentParseError("The string is too long", `${prefix}:length:max`);
}

async parse(parseOptions: ParseOptions) {
const { message, input, rules } = parseOptions;
const parsedArgs: Record<string | number, any> = {};
const argv = input.split(/\s+/);
const args = [...argv];

args.shift();

const state = {
argv,
args,
parsedArgs,
index: 0,
currentArg: argv[0],
rule: rules[0],
parseOptions
} as ParsingState;

let counter = 0;

ruleLoop:
for (state.index = 0; state.index < rules.length; state.index++) {
const rule = rules[state.index];
state.currentArg = state.args[state.index];
state.rule = rules[state.index];

let result = null, lastError: ArgumentParseError | null = null;

for (const type of rule.types) {
const parser = this.parsers[type];
const handler = this[parser] as Function;

if (!parser || !handler) {
throw new Error(`Parser for type "${ArgumentType[type]}" is not implemented.`);
}

state.type = type;

if (!state.currentArg) {
if (!!rule.default || rule.optional) {
result = {
result: rule.default ?? null,
} satisfies ParseResult;

continue;
}
else {
return { error: rule.errors?.['required'] ?? `Argument #${state.index} is required` };
}
}

try {
result = await handler.call(this, state);
lastError = null;
}
catch (error) {
if (error instanceof ArgumentParseError) {
lastError = error;
}
}

if (!lastError) {
break;
}

if (result?.jump === ParserJump.Break)
break ruleLoop;
else if (result?.jump === ParserJump.Next)
continue ruleLoop;
else if (result?.jump === ParserJump.Steps) {
state.index += result?.steps ?? 1;
break;
}
}

if (lastError) {
return { error: rule.errors?.[lastError.type] ?? lastError.message };
}

if (!result) {
return { error: `Failed to parse argument #${state.index}` };
}

state.parsedArgs[rule.name ?? counter++] = result.result;
}

return {
parsedArgs,
};
}
}
93 changes: 93 additions & 0 deletions src/core/CommandArgumentParserInterface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Awaitable, Message } from "discord.js";

export type ParsingState = {
argv: string[];
args: string[];
parsedArgs: Record<string | number, any>;
index: number;
currentArg: string | undefined;
rule: ValidationRule;
parseOptions: ParseOptions;
type: ArgumentType;
};

export enum ArgumentType {
String,
Number,
Integer,
Float,
User,
Role,
Member,
Channel,
StringRest,
Snowflake,
Link,
TimeInterval
}

export type ValidationErrorType =
| "required"
| "type:invalid"
| "entity:null"
| "number:range:min"
| "number:range:max"
| "number:invalid"
| "string:length:min"
| "string:length:max"
| "string:rest:length:min"
| "string:rest:length:max"
| "string:empty"
| "snowflake:invalid"
| "url:invalid"
| "time:invalid";

export type ValidationRuleErrorMessages = { [K in ValidationErrorType]?: string };
export type ValidationRule = {
types: readonly ArgumentType[];
optional?: boolean;
default?: any;
name?: string;
errors?: ValidationRuleErrorMessages;
number?: {
min?: number;
max?: number;
};
string?: {
maxLength?: number;
minLength?: number;
notEmpty?: boolean;
};
time?: {
unit?: 'ms' | 's';
};
link?: {
urlObject?: boolean;
};
entity?: {
allowNull?: boolean;
};
};

export type ParseOptions = {
input: string;
message?: Message;
rules: readonly ValidationRule[];
prefix: string;
};

export enum ParserJump {
Next,
Break,
Steps
}

export type ParseResult<T = any> = {
jump?: ParserJump;
steps?: number;
result?: T;
};

export default interface CommandArgumentParserInterface {
parse(options: ParseOptions): Awaitable<Record<string | number, any>>;
}
Loading

0 comments on commit 12a1c44

Please sign in to comment.