A generic parser for slash command text input
1export type InteractionOption = string | number | boolean
2
3export interface Interaction {
4 command: string
5 text: string
6 subCommands?: string[]
7 options: { [name: string]: InteractionOption }
8}
9
10export interface OptionChoice {
11 name: string
12 value: string | number
13}
14
15export interface OptionDefinition {
16 name: string
17 type: OptionType
18 description?: string
19 options?: OptionDefinition[]
20 required?: boolean
21 min_value?: number
22 max_value?: number
23 min_length?: number
24 max_length?: number
25 choices?: OptionChoice[]
26}
27
28export enum OptionType {
29 sub_command = 1,
30 string = 2,
31 integer = 3,
32 boolean = 4,
33 number = 5,
34 attachment = 6,
35}
36
37const prefix = '/'
38
39export function isCommand(content: string): boolean {
40 try {
41 parseCommand(content)
42 return true
43 } catch {
44 return false
45 }
46}
47
48export function parseCommand(
49 content: string,
50): { command: string; text: string } {
51 if (!content) throw new Error('no content')
52 const matchedPrefix = content.toLowerCase().startsWith(prefix.toLowerCase())
53 if (!matchedPrefix) throw new Error('no prefix (not a command)')
54
55 let text = content.slice(prefix.length).trim()
56 if (!text) throw new Error('no body after prefix')
57
58 const command = text.match(/^[^\s]+/i)?.[0]
59 if (!command) throw new Error('invalid command')
60
61 text = text.slice(command.length).trim()
62
63 return { command, text }
64}
65
66export function parseSubCommands(
67 content: string,
68 max = 2,
69): { subCommands: string[]; remaining: string } {
70 const args = content.split(/ +/).filter((arg) => arg.length > 0)
71
72 const subCommands: string[] = []
73
74 for (let i = 0; i < max; i++) {
75 if (!args.length) break
76 if (args[0]?.endsWith(':')) break
77 if (subCommands.length > i) break
78 subCommands.push(args[0])
79 args.shift()
80 }
81
82 return { subCommands, remaining: args.join(' ') }
83}
84
85export function parseOptions(
86 content: string,
87 template?: OptionDefinition | OptionDefinition[],
88): { [name: string]: InteractionOption } {
89 const args = content.split(/ +/).filter((arg) => arg.length > 0)
90 const options: { [name: string]: InteractionOption } = {}
91
92 let name = ''
93 let value = ''
94
95 args.forEach((arg: string) => {
96 if (arg.endsWith(':')) {
97 if (name) {
98 options[name] = value
99 value = ''
100 }
101 name = arg.slice(0, -1)
102 } else if (name) {
103 if (value.length) value += ' '
104 value += arg
105 } else {
106 throw new Error('Invalid arguments')
107 }
108 })
109
110 if (name) options[name] = value
111
112 if (template) {
113 flattenOptionDefinitions(Array.isArray(template) ? template : [template])
114 .forEach((definition: OptionDefinition) => {
115 const { name, type, required, choices } = definition
116 const value = options[name]
117 if (value == null) {
118 if (required) throw new Error(`missing required option: ${name}`)
119 else return
120 }
121 const parsedValue = parseOptionValue(value, type)
122 if (parsedValue === undefined) {
123 throw new Error(`Invalid option ${name}: ${options[name]}`)
124 }
125 if (choices && !choices.find(({ value }) => value === parsedValue)) {
126 throw new Error(
127 `Option value ${name}: ${parsedValue} is not one of the choices ${choices}`,
128 )
129 }
130 options[name] = parsedValue
131 })
132 }
133
134 return options
135}
136
137function flattenOptionDefinitions(
138 definitions: OptionDefinition[],
139): OptionDefinition[] {
140 let all_options: OptionDefinition[] = []
141 definitions.forEach(({ options, ...def }) => {
142 all_options.push(def)
143 if (options) {
144 all_options = all_options.concat(flattenOptionDefinitions(options))
145 }
146 })
147 return all_options
148}
149
150export function parseOptionValue(
151 value: InteractionOption,
152 type: OptionType,
153): InteractionOption | void {
154 switch (type) {
155 case OptionType.attachment:
156 case OptionType.string:
157 return value
158 case OptionType.integer:
159 case OptionType.number:
160 return Number(value)
161 case OptionType.boolean:
162 return value === 'true'
163 }
164}
165
166export default function parse(
167 content: string,
168 template?: OptionDefinition | OptionDefinition[],
169): Interaction {
170 const { command, text } = parseCommand(content)
171 const { subCommands, remaining } = parseSubCommands(text)
172 const options = parseOptions(remaining, template)
173
174 return { command, text, options, subCommands }
175}