Skip to content

Commit

Permalink
feat: /setup command (#241)
Browse files Browse the repository at this point in the history
  • Loading branch information
virtual-designer authored Oct 5, 2024
2 parents 2d9ec05 + 62e3bac commit a7fe699
Show file tree
Hide file tree
Showing 21 changed files with 1,509 additions and 68 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"estree",
"extbuilds",
"fargs",
"finishable",
"httpcat",
"httpdog",
"kickable",
Expand Down Expand Up @@ -98,6 +99,7 @@
"Unbans",
"undici",
"Unmutes",
"uuidv",
"xnor"
],
"material-icon-theme.folders.associations": {
Expand Down
3 changes: 2 additions & 1 deletion extensions/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
antirickroll
urlfish
urlfish
archiver
2 changes: 2 additions & 0 deletions src/framework/typescript/commands/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export type ContextOf<T extends Command<ContextType>> =
: never;
export type AnyContext = ContextOf<AnyCommand>;

export type ContextReplyOptions = Parameters<Context["reply"]>[0];

abstract class Context<T extends CommandMessage = CommandMessage> {
public readonly commandName: string;
public readonly commandMessage: T;
Expand Down
8 changes: 8 additions & 0 deletions src/framework/typescript/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,11 @@ export function suppressErrorNoReturn(value: unknown): void {
value.catch(Application.current().logger.error);
}
}

export function sourceFile(moduleName: string): string {
if (process.isBun) {
return `${moduleName}.ts`;
}

return `${moduleName}.js`;
}
128 changes: 128 additions & 0 deletions src/framework/typescript/widgets/Wizard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type Context from "@framework/commands/Context";
import type { ContextReplyOptions } from "@framework/commands/Context";
import WizardButtonBuilder from "@framework/widgets/WizardButtonBuilder";
import type WizardManager from "@framework/widgets/WizardManager";
import type { ButtonInteraction } from "discord.js";
import {
ActionRowBuilder,
ButtonStyle,
type AnyComponentBuilder,
type Awaitable,
type Message,
type MessageEditOptions
} from "discord.js";
import { v4 as uuidv4 } from "uuid";

abstract class Wizard {
private readonly manager: WizardManager;
private readonly context: Context;
private message: Message | null = null;
private handlers: Record<string, string> = {};
private readonly id = uuidv4();
protected readonly inactivityTimeout: number = 300000; /* 5 minutes */
private timeout: Timer | null = null;
protected readonly states: ContextReplyOptions[] = [];

public constructor(manager: WizardManager, context: Context) {
this.context = context;
this.manager = manager;
}

protected button(customId: string): WizardButtonBuilder {
return new WizardButtonBuilder()
.setCustomId(`w::${this.id}::${customId}`)
.setStyle(ButtonStyle.Secondary);
}

protected row<T extends AnyComponentBuilder>(components: T[]) {
return new ActionRowBuilder<T>().addComponents(...components);
}

protected abstract render(): Awaitable<ContextReplyOptions>;

public async update(): Promise<void> {
const options = this.states.at(-1) ?? (await this.render());

this.handlers = {};

if (typeof options === "object" && "components" in options) {
for (const component of options.components ?? []) {
if (component instanceof ActionRowBuilder) {
for (const button of component.components) {
if (button instanceof WizardButtonBuilder) {
this.handlers[button.customId] = button.handler;
}
}
}
}
}

if (!this.message) {
this.message = await this.context.reply(options);
this.manager.register(this.id, this);
this.timeout = setTimeout(() => this.dispose(), this.inactivityTimeout);
} else {
await this.message.edit(options as MessageEditOptions);
}
}

protected pushState(options: ContextReplyOptions) {
this.states.push(options);
}

protected popState() {
return this.states.pop();
}

protected async revertState(interaction?: ButtonInteraction) {
const state = this.popState();

if (interaction) {
await interaction.deferUpdate();
}

if (state) {
await this.update();
}

return state;
}

public async dispatch(interaction: ButtonInteraction, customId: string) {
const handler = this.handlers[customId];

if (this.timeout) {
clearTimeout(this.timeout);
}

this.timeout = setTimeout(() => this.dispose(), this.inactivityTimeout);

if (handler in this && typeof this[handler as keyof this] === "function") {
const result = await (
this as unknown as Record<
string,
(
interaction: ButtonInteraction,
customId: string
) => Awaitable<ContextReplyOptions>
>
)[handler].call(this, interaction, customId);

if (result) {
this.pushState(result);

if (!interaction.deferred) {
await interaction.deferUpdate();
}

await interaction.message.edit(result as MessageEditOptions);
}
}
}

public dispose() {
this.manager.dispose(this.id);
}
}

export default Wizard;
26 changes: 26 additions & 0 deletions src/framework/typescript/widgets/WizardButtonBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ButtonBuilder } from "discord.js";

class WizardButtonBuilder extends ButtonBuilder {
private _customId?: string;
private _handler?: string;

public override setCustomId(customId: string): this {
this._customId = customId;
return super.setCustomId(customId);
}

public get customId(): string {
return this._customId!;
}

public setHandler(handler: string): this {
this._handler = handler;
return this;
}

public get handler(): string {
return this._handler!;
}
}

export default WizardButtonBuilder;
20 changes: 20 additions & 0 deletions src/framework/typescript/widgets/WizardManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { HasApplication } from "@framework/types/HasApplication";
import type Wizard from "@framework/widgets/Wizard";

class WizardManager extends HasApplication {
private readonly wizards: Map<string, Wizard> = new Map();

public register(name: string, wizard: Wizard): void {
this.wizards.set(name, wizard);
}

public get(name: string): Wizard | undefined {
return this.wizards.get(name);
}

public dispose(name: string): void {
this.wizards.delete(name);
}
}

export default WizardManager;
50 changes: 50 additions & 0 deletions src/main/typescript/commands/settings/SetupCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* This file is part of SudoBot.
*
* Copyright (C) 2021, 2022, 2023, 2024 OSN Developers.
*
* SudoBot is free software; you can redistribute it and/or modify it
* under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* SudoBot is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
*/

import { Command } from "@framework/commands/Command";
import { ContextType } from "@framework/commands/ContextType";
import type InteractionContext from "@framework/commands/InteractionContext";
import { PermissionFlags } from "@framework/permissions/PermissionFlag";
import type { ChatInputCommandInteraction, Guild } from "discord.js";

class SetupCommand extends Command<ContextType> {
public override readonly name = "setup";
public override readonly description: string = "Setup the bot for this server.";
public override readonly usage = [""];
public override readonly permissions = [PermissionFlags.ManageGuild];
public override readonly supportedContexts = [ContextType.ChatInput];

public override async execute(context: InteractionContext<ChatInputCommandInteraction>) {
const { commandMessage } = context;

if (!context.member || !commandMessage.guild) {
await context.error("This command can only be used in a server.");
return;
}

await this.application.service("guildSetupService").initialize(
commandMessage as ChatInputCommandInteraction & {
guild: Guild;
},
context.user.id
);
}
}

export default SetupCommand;
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class UpdateCommandsCommand extends Command {
`Successfully ${clear ? "unregistered" : "updated"} **${clear ? "all" : count}** ${local ? "local " : ""}application commands.`
);
} catch (error) {
this.application.logger.error(error);

if (isDiscordAPIError(error)) {
await context.error(`Failed to update the application commands: ${error.message}`);
return;
Expand Down
2 changes: 2 additions & 0 deletions src/main/typescript/core/DiscordKernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class DiscordKernel extends Kernel {
"@services/SurveyService",
"@services/ShellService",
"@services/AuthService",
"@services/WizardManagerService",
"@services/GuildSetupService",
"@services/SnippetManagerService",
"@services/TranslationService",
"@services/SystemUpdateService",
Expand Down
42 changes: 38 additions & 4 deletions src/main/typescript/events/guild/GuildCreateEventListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,60 @@ import { Inject } from "@framework/container/Inject";
import EventListener from "@framework/events/EventListener";
import { Logger } from "@framework/log/Logger";
import { Events } from "@framework/types/ClientEvents";
import { fetchMember } from "@framework/utils/entities";
import GuildSetupService from "@main/services/GuildSetupService";
import type { Guild } from "discord.js";
import type Client from "../../core/Client";
import ConfigurationManager from "../../services/ConfigurationManager";
import type { Guild } from "discord.js";

class GuildCreateEventListener extends EventListener<Events.GuildCreate, Client> {
public override readonly name = Events.GuildCreate;

@Inject()
public readonly configManager!: ConfigurationManager;
private readonly configManager!: ConfigurationManager;

@Inject()
private readonly logger!: Logger;

@Inject()
public readonly logger!: Logger;
private readonly guildSetupService!: GuildSetupService;

public override execute(guild: Guild) {
public override async execute(guild: Guild) {
this.logger.info(`Joined a guild: ${guild.name} (${guild.id})`);

if (!this.configManager.config[guild.id]) {
this.logger.info(`Auto-configuring guild: ${guild.id}`);
this.configManager.autoConfigure(guild.id);

await this.configManager.write({
system: false,
guild: true
});
await this.configManager.load();
}

const integration = await guild.fetchIntegrations();
const id = this.client.application?.id;

if (!id) {
return;
}

const systemIntegration = integration.find(
integration => integration.application?.id === id
);

if (!systemIntegration?.user) {
return;
}

const member = await fetchMember(guild, systemIntegration.user.id);

if (!member) {
return;
}

await this.guildSetupService.initialize(member, member.id).catch(this.logger.error);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Inject } from "@framework/container/Inject";
import EventListener from "@framework/events/EventListener";
import { Events } from "@framework/types/ClientEvents";
import type VerificationService from "@main/automod/VerificationService";
import type WizardManagerService from "@main/services/WizardManagerService";
import { Interaction } from "discord.js";
import type CommandManager from "../../services/CommandManager";

Expand All @@ -33,13 +34,17 @@ class InteractionCreateEventListener extends EventListener<Events.InteractionCre
@Inject("verificationService")
private readonly verificationService!: VerificationService;

@Inject("wizardManagerService")
private readonly wizardManagerService!: WizardManagerService;

public override async execute(interaction: Interaction): Promise<void> {
if (interaction.isCommand()) {
await this.commandManager.runCommandFromInteraction(interaction);
}

if (interaction.isButton()) {
await this.verificationService.onInteractionCreate(interaction);
this.wizardManagerService.onInteractionCreate(interaction);
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/main/typescript/extensions/Extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export abstract class Extension {
return [];
}

public initialize(): Awaitable<void> {}
public cleanup(): Awaitable<void> {}

public guildConfig(): Awaitable<
| {
[K in PropertyKey]: ZodSchema<unknown>;
Expand Down
Loading

0 comments on commit a7fe699

Please sign in to comment.