1 |
/* |
2 |
* This file is part of SudoBot. |
3 |
* |
4 |
* Copyright (C) 2021-2023 OSN Developers. |
5 |
* |
6 |
* SudoBot is free software; you can redistribute it and/or modify it |
7 |
* under the terms of the GNU Affero General Public License as published by |
8 |
* the Free Software Foundation, either version 3 of the License, or |
9 |
* (at your option) any later version. |
10 |
* |
11 |
* SudoBot is distributed in the hope that it will be useful, but |
12 |
* WITHOUT ANY WARRANTY; without even the implied warranty of |
13 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 |
* GNU Affero General Public License for more details. |
15 |
* |
16 |
* You should have received a copy of the GNU Affero General Public License |
17 |
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>. |
18 |
*/ |
19 |
|
20 |
import { |
21 |
APIEmbed, |
22 |
APIEmbedField, |
23 |
APIMessage, |
24 |
ApplicationCommandType, |
25 |
Awaitable, |
26 |
CacheType, |
27 |
Channel, |
28 |
ChatInputCommandInteraction, |
29 |
ContextMenuCommandBuilder, |
30 |
ContextMenuCommandInteraction, |
31 |
GuildMember, |
32 |
InteractionDeferReplyOptions, |
33 |
InteractionEditReplyOptions, |
34 |
InteractionReplyOptions, |
35 |
Message, |
36 |
MessageCreateOptions, |
37 |
MessagePayload, |
38 |
MessageReplyOptions, |
39 |
ModalSubmitInteraction, |
40 |
PermissionResolvable, |
41 |
SlashCommandBuilder, |
42 |
TextBasedChannel, |
43 |
User, |
44 |
SlashCommandOptionsOnlyBuilder, |
45 |
SlashCommandSubcommandsOnlyBuilder |
46 |
} from "discord.js"; |
47 |
import { |
48 |
ChatInputCommandContext, |
49 |
ContextMenuCommandContext, |
50 |
LegacyCommandContext |
51 |
} from "../services/CommandManager"; |
52 |
import EmbedSchemaParser from "../utils/EmbedSchemaParser"; |
53 |
import { logError } from "../utils/Logger"; |
54 |
import { channelInfo, guildInfo, userInfo } from "../utils/embed"; |
55 |
import { safeChannelFetch } from "../utils/fetch"; |
56 |
import { getEmoji } from "../utils/utils"; |
57 |
import Client from "./Client"; |
58 |
import CommandArgumentParser from "./CommandArgumentParser"; |
59 |
import { ValidationRule } from "./CommandArgumentParserInterface"; |
60 |
|
61 |
export * from "./CommandArgumentParserInterface"; |
62 |
|
63 |
export type CommandMessage = |
64 |
| Message<boolean> |
65 |
| ChatInputCommandInteraction<CacheType> |
66 |
| ContextMenuCommandInteraction; |
67 |
export type BasicCommandContext = LegacyCommandContext | ChatInputCommandContext; |
68 |
export type AnyCommandContext = BasicCommandContext | ContextMenuCommandContext; |
69 |
export type CommandReturn = |
70 |
| ((MessageCreateOptions | APIMessage | InteractionReplyOptions) & { __reply?: boolean }) |
71 |
| undefined |
72 |
| null |
73 |
| void; |
74 |
|
75 |
type CommandSlashCommandBuilder = |
76 |
| Partial<Pick<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">> |
77 |
| Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup"> |
78 |
| SlashCommandBuilder |
79 |
| SlashCommandOptionsOnlyBuilder |
80 |
| SlashCommandSubcommandsOnlyBuilder; |
81 |
|
82 |
type DeferReplyMode = "delete" | "channel" | "default" | "auto"; |
83 |
type DeferReplyOptions = |
84 |
| ((MessageCreateOptions | MessagePayload | InteractionEditReplyOptions) & { |
85 |
ephemeral?: boolean; |
86 |
}) |
87 |
| string; |
88 |
type PermissionValidationResult = |
89 |
| boolean |
90 |
| { |
91 |
isPermitted: boolean; |
92 |
source?: string; |
93 |
}; |
94 |
export type RunCommandOptions = { |
95 |
message: CommandMessage; |
96 |
context: AnyCommandContext; |
97 |
onAbort?: () => unknown; |
98 |
checkOnly?: boolean; |
99 |
}; |
100 |
|
101 |
export class PermissionError extends Error {} |
102 |
type MessageOptions = string | MessageCreateOptions | MessagePayload | InteractionReplyOptions; |
103 |
|
104 |
export class CommandAbortedError extends Error { |
105 |
public readonly options?: MessageOptions | null; |
106 |
|
107 |
constructor( |
108 |
public readonly reason: string, |
109 |
messageOptionsOrCallback?: |
110 |
| MessageOptions |
111 |
| null |
112 |
| (() => MessageOptions | void | undefined | null) |
113 |
) { |
114 |
super(reason); |
115 |
this.options = |
116 |
typeof messageOptionsOrCallback === "function" |
117 |
? messageOptionsOrCallback() ?? undefined |
118 |
: messageOptionsOrCallback === undefined |
119 |
? { |
120 |
content: reason |
121 |
} |
122 |
: messageOptionsOrCallback; |
123 |
} |
124 |
|
125 |
public send(message: CommandMessage) { |
126 |
if (this.options) { |
127 |
return message.reply(this.options as MessagePayload); |
128 |
} |
129 |
} |
130 |
} |
131 |
|
132 |
type SubcommandMeta = { |
133 |
description?: string; |
134 |
detailedDescription?: string; |
135 |
argumentSyntaxes?: string[]; |
136 |
availableOptions?: Record<string, string>; |
137 |
beta?: boolean; |
138 |
since?: string; |
139 |
botRequiredPermissions?: PermissionResolvable[]; |
140 |
permissions?: PermissionResolvable[]; |
141 |
supportsLegacy?: boolean; |
142 |
supportsInteractions?: boolean; |
143 |
systemAdminOnly?: boolean; |
144 |
}; |
145 |
|
146 |
export default abstract class Command { |
147 |
public readonly name: string = ""; |
148 |
public group: string = "Default"; |
149 |
public readonly aliases: string[] = []; |
150 |
|
151 |
public readonly supportsInteractions: boolean = true; |
152 |
public readonly supportsLegacy: boolean = true; |
153 |
|
154 |
public readonly permissions: PermissionResolvable[] = []; |
155 |
public readonly validationRules: readonly ValidationRule[] = []; |
156 |
public readonly permissionMode: "or" | "and" = "and"; |
157 |
public readonly systemAdminOnly: boolean = false; |
158 |
|
159 |
public readonly description?: string; |
160 |
public readonly detailedDescription?: string; |
161 |
public readonly argumentSyntaxes?: string[]; |
162 |
public readonly availableOptions?: Record<string, string>; |
163 |
public readonly beta: boolean = false; |
164 |
public readonly since: string = "1.0.0"; |
165 |
public readonly botRequiredPermissions: PermissionResolvable[] = []; |
166 |
public readonly slashCommandBuilder?: CommandSlashCommandBuilder; |
167 |
|
168 |
public readonly applicationCommandType: ApplicationCommandType = |
169 |
ApplicationCommandType.ChatInput; |
170 |
public readonly otherApplicationCommandBuilders: ( |
171 |
| ContextMenuCommandBuilder |
172 |
| SlashCommandBuilder |
173 |
)[] = []; |
174 |
|
175 |
public readonly subcommands: string[] = []; |
176 |
public readonly subCommandCheck: boolean = false; |
177 |
public readonly cooldown?: number = undefined; |
178 |
public readonly subcommandsMeta: Record<string, SubcommandMeta> = {}; |
179 |
|
180 |
protected message?: CommandMessage; |
181 |
|
182 |
protected static argumentParser = new CommandArgumentParser(Client.instance); |
183 |
|
184 |
protected constructor(protected client: Client<true>) {} |
185 |
|
186 |
abstract execute( |
187 |
message: CommandMessage, |
188 |
context: AnyCommandContext, |
189 |
options?: RunCommandOptions |
190 |
): Promise<CommandReturn>; |
191 |
|
192 |
protected getCommandMessage(): CommandMessage { |
193 |
if (!this.message) { |
194 |
throw new TypeError(`${this.constructor.name}.message is undefined`); |
195 |
} |
196 |
|
197 |
return this.message; |
198 |
} |
199 |
|
200 |
protected async deferIfInteraction( |
201 |
message: CommandMessage, |
202 |
options?: InteractionDeferReplyOptions |
203 |
) { |
204 |
if (message instanceof ChatInputCommandInteraction) |
205 |
return await message.deferReply(options).catch(logError); |
206 |
} |
207 |
|
208 |
public async deferredReply( |
209 |
message: CommandMessage, |
210 |
options: DeferReplyOptions, |
211 |
mode: DeferReplyMode = "default" |
212 |
) { |
213 |
if ( |
214 |
message instanceof ChatInputCommandInteraction || |
215 |
message instanceof ContextMenuCommandInteraction |
216 |
) { |
217 |
if (message.deferred) { |
218 |
return message.editReply(options); |
219 |
} |
220 |
|
221 |
const response = await message.reply(options as InteractionReplyOptions); |
222 |
return response.fetch(); |
223 |
} |
224 |
|
225 |
const behaviour = |
226 |
this.client.configManager.config[message.guildId!]?.commands |
227 |
.moderation_command_behaviour; |
228 |
|
229 |
if (mode === "delete" || (mode === "auto" && behaviour === "delete")) { |
230 |
await message.delete().catch(logError); |
231 |
} |
232 |
|
233 |
return mode === "delete" || |
234 |
(mode === "auto" && behaviour === "delete") || |
235 |
mode === "channel" |
236 |
? message.channel.send(options as MessageCreateOptions) |
237 |
: message.reply(options as MessageReplyOptions); |
238 |
} |
239 |
|
240 |
protected async error( |
241 |
message: CommandMessage, |
242 |
errorMessage?: string, |
243 |
mode: DeferReplyMode = "default" |
244 |
) { |
245 |
return await this.deferredReply( |
246 |
message, |
247 |
errorMessage |
248 |
? `${this.emoji("error")} ${errorMessage}` |
249 |
: "⚠️ An error has occurred while performing this action. Please make sure that the bot has the required permissions to perform this action.", |
250 |
mode |
251 |
); |
252 |
} |
253 |
|
254 |
protected async success( |
255 |
message: CommandMessage, |
256 |
successMessage?: string, |
257 |
mode: DeferReplyMode = "default" |
258 |
) { |
259 |
return await this.deferredReply( |
260 |
message, |
261 |
successMessage |
262 |
? `${this.emoji("check")} ${successMessage}` |
263 |
: "Successfully completed the given task.", |
264 |
mode |
265 |
); |
266 |
} |
267 |
|
268 |
protected sendError(errorMessage?: string, mode: DeferReplyMode = "default") { |
269 |
return this.error(this.getCommandMessage(), errorMessage, mode); |
270 |
} |
271 |
|
272 |
protected sendSuccess(successMessage?: string, mode: DeferReplyMode = "default") { |
273 |
return this.success(this.getCommandMessage(), successMessage, mode); |
274 |
} |
275 |
|
276 |
protected abort( |
277 |
options: |
278 |
| string |
279 |
| MessageCreateOptions |
280 |
| MessagePayload |
281 |
| InteractionReplyOptions |
282 |
| null |
283 |
| undefined = null |
284 |
) { |
285 |
throw new CommandAbortedError(`Command aborted: ${this.name}`, options); |
286 |
} |
287 |
|
288 |
protected emoji(name: string) { |
289 |
return getEmoji(this.client, name); |
290 |
} |
291 |
|
292 |
protected async sendCommandRanLog( |
293 |
message: CommandMessage | ModalSubmitInteraction, |
294 |
options: APIEmbed, |
295 |
params: { |
296 |
fields?: (fields: APIEmbedField[]) => Awaitable<APIEmbedField[]>; |
297 |
before?: ( |
298 |
channel: TextBasedChannel, |
299 |
sentMessages: Array<Message | null> |
300 |
) => Awaitable<void>; |
301 |
previews?: Array<MessageCreateOptions | MessagePayload>; |
302 |
url?: string | null; |
303 |
} = {} |
304 |
) { |
305 |
if (!this.client.configManager.systemConfig.logging?.enabled) { |
306 |
return; |
307 |
} |
308 |
|
309 |
const { previews = [] } = params; |
310 |
const logChannelId = |
311 |
this.client.configManager.systemConfig.logging?.channels?.echo_send_logs; |
312 |
|
313 |
if (!logChannelId) { |
314 |
return; |
315 |
} |
316 |
|
317 |
try { |
318 |
const channel = await safeChannelFetch(await this.client.getHomeGuild(), logChannelId); |
319 |
|
320 |
if (!channel?.isTextBased()) { |
321 |
return; |
322 |
} |
323 |
|
324 |
const sentMessages = []; |
325 |
|
326 |
for (const preview of previews) { |
327 |
const sentMessage = await EmbedSchemaParser.sendMessage(channel, { |
328 |
...preview, |
329 |
reply: undefined |
330 |
} as MessageCreateOptions).catch(logError); |
331 |
sentMessages.push(sentMessage ?? null); |
332 |
} |
333 |
|
334 |
await params.before?.(channel, sentMessages); |
335 |
|
336 |
const embedFields = [ |
337 |
{ |
338 |
name: "Command Name", |
339 |
value: this.name |
340 |
}, |
341 |
{ |
342 |
name: "Guild Info", |
343 |
value: guildInfo(message.guild!), |
344 |
inline: true |
345 |
}, |
346 |
{ |
347 |
name: "Channel Info", |
348 |
value: channelInfo(message.channel as Channel), |
349 |
inline: true |
350 |
}, |
351 |
{ |
352 |
name: "User (Executor)", |
353 |
value: userInfo(message.member!.user as User), |
354 |
inline: true |
355 |
}, |
356 |
{ |
357 |
name: "Mode", |
358 |
value: message instanceof Message ? "Legacy" : "Application Interaction" |
359 |
}, |
360 |
...(params.url !== null |
361 |
? [ |
362 |
{ |
363 |
name: "Message URL", |
364 |
value: |
365 |
params.url ?? |
366 |
(message instanceof Message ? message.url : "*Not available*") |
367 |
} |
368 |
] |
369 |
: []) |
370 |
]; |
371 |
|
372 |
await channel |
373 |
?.send({ |
374 |
embeds: [ |
375 |
{ |
376 |
author: { |
377 |
name: message.member?.user.username as string, |
378 |
icon_url: (message.member?.user as User)?.displayAvatarURL() |
379 |
}, |
380 |
title: "A command was executed", |
381 |
color: 0x007bff, |
382 |
fields: (await params.fields?.(embedFields)) ?? embedFields, |
383 |
...options |
384 |
} |
385 |
] |
386 |
}) |
387 |
.catch(logError); |
388 |
} catch (error) { |
389 |
logError(error); |
390 |
} |
391 |
} |
392 |
|
393 |
/** |
394 |
* Check for command cooldowns. |
395 |
* |
396 |
* @param message The target message |
397 |
* @returns {Promise<boolean>} Whether to abort the command |
398 |
*/ |
399 |
private async cooldownCheck(message: CommandMessage): Promise<boolean> { |
400 |
if (!this.cooldown) { |
401 |
return false; |
402 |
} |
403 |
|
404 |
const { cooldown, enabled } = this.client.cooldown.lock( |
405 |
message.guildId!, |
406 |
this.name, |
407 |
this.cooldown |
408 |
); |
409 |
|
410 |
if (enabled) { |
411 |
const seconds = Math.max(Math.ceil(((cooldown ?? 0) - Date.now()) / 1000), 1); |
412 |
|
413 |
await this.deferredReply(message, { |
414 |
embeds: [ |
415 |
{ |
416 |
description: `${this.emoji( |
417 |
"clock_red" |
418 |
)} You're being rate limited, please wait for **${seconds}** second${ |
419 |
seconds === 1 ? "" : "s" |
420 |
}.`, |
421 |
color: 0xf14a60 |
422 |
} |
423 |
], |
424 |
ephemeral: true |
425 |
}); |
426 |
|
427 |
return true; |
428 |
} |
429 |
|
430 |
return false; |
431 |
} |
432 |
|
433 |
protected async validateCommandBuiltInPermissions( |
434 |
member: GuildMember, |
435 |
hasOverwrite: boolean |
436 |
): Promise<boolean> { |
437 |
if ( |
438 |
this.client.configManager.systemConfig.default_permissions_mode === "ignore" || |
439 |
(hasOverwrite && |
440 |
this.client.configManager.systemConfig.default_permissions_mode === "overwrite") |
441 |
) { |
442 |
return true; |
443 |
} |
444 |
|
445 |
return ( |
446 |
(this.permissionMode === "and" && member.permissions.has(this.permissions, true)) || |
447 |
(this.permissionMode === "or" && member.permissions.any(this.permissions, true)) |
448 |
); |
449 |
} |
450 |
|
451 |
protected async validateNonSystemAdminPermissions({ message }: RunCommandOptions) { |
452 |
const commandName = this.name; |
453 |
const { disabled_commands } = this.client.configManager.systemConfig; |
454 |
|
455 |
if (disabled_commands.includes(commandName ?? "")) { |
456 |
throw new PermissionError("This command is disabled."); |
457 |
} |
458 |
|
459 |
const { channels, guild } = |
460 |
this.client.configManager.config[message.guildId!]?.disabled_commands ?? {}; |
461 |
|
462 |
if (guild && guild.includes(commandName ?? "")) { |
463 |
throw new PermissionError("This command is disabled in this server."); |
464 |
} |
465 |
|
466 |
if ( |
467 |
channels && |
468 |
channels[message.channelId!] && |
469 |
channels[message.channelId!].includes(commandName ?? "") |
470 |
) { |
471 |
throw new PermissionError("This command is disabled in this channel."); |
472 |
} |
473 |
|
474 |
return true; |
475 |
} |
476 |
|
477 |
protected validateSystemAdminPermissions(member: GuildMember) { |
478 |
const isSystemAdmin = this.client.configManager.systemConfig.system_admins.includes( |
479 |
member.id |
480 |
); |
481 |
|
482 |
if (this.systemAdminOnly && !isSystemAdmin) { |
483 |
throw new PermissionError("This command is only available to system administrators."); |
484 |
} |
485 |
|
486 |
return { |
487 |
isSystemAdmin |
488 |
}; |
489 |
} |
490 |
|
491 |
protected async validatePermissions( |
492 |
options: RunCommandOptions |
493 |
): Promise<{ result?: PermissionValidationResult; abort?: boolean; error?: string }> { |
494 |
const { message } = options; |
495 |
|
496 |
try { |
497 |
const { isSystemAdmin } = this.validateSystemAdminPermissions( |
498 |
message.member as GuildMember |
499 |
); |
500 |
|
501 |
if (isSystemAdmin) { |
502 |
return { result: true }; |
503 |
} |
504 |
|
505 |
if (this.client.commandManager.isBanned(message.member!.user.id)) { |
506 |
return { abort: true }; |
507 |
} |
508 |
|
509 |
const builtInValidationResult = await this.validateCommandBuiltInPermissions( |
510 |
message.member as GuildMember, |
511 |
!!this.client.commandPermissionOverwriteManager.permissionOverwrites.get( |
512 |
`${message.guildId!}____${this.name}` |
513 |
)?.length |
514 |
); |
515 |
|
516 |
if (!builtInValidationResult) { |
517 |
return { result: false }; |
518 |
} |
519 |
|
520 |
const { result: permissionOverwriteResult } = |
521 |
await this.client.commandPermissionOverwriteManager.validatePermissionOverwrites({ |
522 |
commandName: this.name, |
523 |
guildId: message.guildId!, |
524 |
member: message.member as GuildMember, |
525 |
channelId: message.channelId! |
526 |
}); |
527 |
|
528 |
if (!permissionOverwriteResult) { |
529 |
return { |
530 |
error: "You don't have enough permissions to run this command." |
531 |
}; |
532 |
} |
533 |
|
534 |
return { |
535 |
result: await this.validateNonSystemAdminPermissions(options) |
536 |
}; |
537 |
} catch (error) { |
538 |
if (error instanceof PermissionError) { |
539 |
return { |
540 |
error: error.message |
541 |
}; |
542 |
} |
543 |
|
544 |
logError(error); |
545 |
|
546 |
return { |
547 |
abort: true |
548 |
}; |
549 |
} |
550 |
} |
551 |
|
552 |
protected async doChecks(options: RunCommandOptions) { |
553 |
const { message } = options; |
554 |
const { |
555 |
result: permissionValidationResult, |
556 |
abort, |
557 |
error |
558 |
} = await this.validatePermissions(options); |
559 |
|
560 |
if (abort) { |
561 |
return; |
562 |
} |
563 |
|
564 |
const isPermitted = |
565 |
(typeof permissionValidationResult === "boolean" && permissionValidationResult) || |
566 |
(typeof permissionValidationResult === "object" && |
567 |
permissionValidationResult.isPermitted); |
568 |
|
569 |
if (!isPermitted || error) { |
570 |
const permissionValidationFailureSource = |
571 |
(typeof permissionValidationResult === "object" && |
572 |
permissionValidationResult.source) || |
573 |
undefined; |
574 |
const debugMode = |
575 |
(this.client.configManager.systemConfig.debug_mode || |
576 |
this.client.configManager.config[message.guildId!]?.debug_mode) ?? |
577 |
false; |
578 |
|
579 |
await this.error( |
580 |
message, |
581 |
`${error ?? "You don't have permission to run this command."}${ |
582 |
debugMode && permissionValidationFailureSource |
583 |
? `\nSource: \`${permissionValidationFailureSource}\`` |
584 |
: "" |
585 |
}` |
586 |
); |
587 |
|
588 |
return false; |
589 |
} |
590 |
|
591 |
if (this.cooldown) { |
592 |
const abort = await this.cooldownCheck(message); |
593 |
|
594 |
if (abort) { |
595 |
return; |
596 |
} |
597 |
} |
598 |
|
599 |
return true; |
600 |
} |
601 |
|
602 |
protected async parseArguments({ message, context }: RunCommandOptions) { |
603 |
if (!(message instanceof Message) || !context.isLegacy) { |
604 |
return true; |
605 |
} |
606 |
|
607 |
const { error, parsedArgs } = await Command.argumentParser.parse({ |
608 |
message, |
609 |
input: message.content, |
610 |
prefix: context.prefix, |
611 |
rules: this.validationRules |
612 |
}); |
613 |
|
614 |
if (error) { |
615 |
await this.error(message, error); |
616 |
return false; |
617 |
} |
618 |
|
619 |
if ( |
620 |
context.isLegacy && |
621 |
this.subCommandCheck && |
622 |
!this.subcommands.includes(context.args[0]) |
623 |
) { |
624 |
await this.error( |
625 |
message, |
626 |
`Please provide a valid subcommand! The valid subcommands are \`${this.subcommands.join( |
627 |
"`, `" |
628 |
)}\`.` |
629 |
); |
630 |
|
631 |
return false; |
632 |
} |
633 |
|
634 |
return parsedArgs; |
635 |
} |
636 |
|
637 |
async run(options: RunCommandOptions) { |
638 |
const { message, context, checkOnly = false, onAbort } = options; |
639 |
|
640 |
this.message = message; |
641 |
|
642 |
if (!(await this.doChecks(options))) { |
643 |
this.message = undefined; |
644 |
onAbort?.(); |
645 |
return; |
646 |
} |
647 |
|
648 |
const parsedArgs = await this.parseArguments(options); |
649 |
|
650 |
if (parsedArgs === false) { |
651 |
this.message = undefined; |
652 |
onAbort?.(); |
653 |
return; |
654 |
} |
655 |
|
656 |
if (checkOnly) { |
657 |
this.message = undefined; |
658 |
return; |
659 |
} |
660 |
|
661 |
if (typeof parsedArgs === "object" && context.isLegacy) { |
662 |
context.parsedArgs = []; |
663 |
|
664 |
for (const key in parsedArgs) { |
665 |
context.parsedArgs[key as unknown as number] = parsedArgs[key]; |
666 |
} |
667 |
|
668 |
context.parsedNamedArgs = parsedArgs; |
669 |
} |
670 |
|
671 |
try { |
672 |
const commandReturn = await this.execute(message, context); |
673 |
this.message = undefined; |
674 |
return commandReturn; |
675 |
} catch (error) { |
676 |
if (error instanceof CommandAbortedError) { |
677 |
if (error.options) { |
678 |
await this.deferredReply(message, error.options); |
679 |
} |
680 |
|
681 |
this.message = undefined; |
682 |
onAbort?.(); |
683 |
return; |
684 |
} |
685 |
|
686 |
throw error; |
687 |
} |
688 |
} |
689 |
} |