-
-
Notifications
You must be signed in to change notification settings - Fork 27
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
430b511
commit 12a1c44
Showing
6 changed files
with
733 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>>; | ||
} |
Oops, something went wrong.