From fe448ce99cc6feb38c793b78ac15125b8e71cbf9 Mon Sep 17 00:00:00 2001 From: Reya C Date: Sun, 9 Oct 2022 19:00:23 -0400 Subject: [PATCH] Enable Gulp-chan to shut down, restart, and rebuild herself. --- src/commands/bot/index.ts | 21 ++ src/commands/bot/rebuild.ts | 78 ++++ src/commands/bot/restart.ts | 39 ++ src/commands/bot/shutdown.ts | 27 ++ src/commands/character/create.ts | 25 ++ src/commands/character/index.ts | 135 +++++++ src/commands/index.ts | 356 +++++++++++++++++++ src/commands/types.ts | 154 ++++++++ src/defaultPresence.ts | 11 + src/ipc/restart.ts | 200 +++++++++++ src/main.spec.ts | 8 +- src/main.ts | 588 +++++-------------------------- src/types/interactions.ts | 5 + tsconfig.json | 3 +- 14 files changed, 1153 insertions(+), 497 deletions(-) create mode 100644 src/commands/bot/index.ts create mode 100644 src/commands/bot/rebuild.ts create mode 100644 src/commands/bot/restart.ts create mode 100644 src/commands/bot/shutdown.ts create mode 100644 src/commands/character/create.ts create mode 100644 src/commands/character/index.ts create mode 100644 src/commands/index.ts create mode 100644 src/commands/types.ts create mode 100644 src/defaultPresence.ts create mode 100644 src/ipc/restart.ts create mode 100644 src/types/interactions.ts diff --git a/src/commands/bot/index.ts b/src/commands/bot/index.ts new file mode 100644 index 0000000..1f45867 --- /dev/null +++ b/src/commands/bot/index.ts @@ -0,0 +1,21 @@ +import {BaseChatInputCommandData, CommandWithSubcommandsData} from "../types.js" +import {ApplicationCommandType} from "discord.js" +import {commandBotRestart} from "./restart.js" +import {commandBotShutdown} from "./shutdown.js" +import {commandBotRebuild} from "./rebuild.js" + +class BotCommandData extends CommandWithSubcommandsData { + readonly baseDefinition: BaseChatInputCommandData = { + name: "bot", + type: ApplicationCommandType.ChatInput, + description: "Commands to manage the bot's status.", + } + + readonly subcommands = [ + commandBotRebuild, + commandBotRestart, + commandBotShutdown, + ] +} + +export const commandBot = new BotCommandData() \ No newline at end of file diff --git a/src/commands/bot/rebuild.ts b/src/commands/bot/rebuild.ts new file mode 100644 index 0000000..cdbba56 --- /dev/null +++ b/src/commands/bot/rebuild.ts @@ -0,0 +1,78 @@ +import {adminId, SubcommandData} from "../types.js" +import { + ActivityType, + ApplicationCommandOptionType, + ApplicationCommandSubCommandData, + ChatInputCommandInteraction, +} from "discord.js" +import {wrappedRestart} from "../../ipc/restart.js" +import {spawn} from "child_process" +import {defaultPresence} from "../../defaultPresence.js" +import {resolve as resolvePath} from "path" + +class RebuildCommand extends SubcommandData { + readonly definition: ApplicationCommandSubCommandData = { + name: "rebuild", + description: "Rebuilds and restarts the bot.", + type: ApplicationCommandOptionType.Subcommand, + } + + async execute(b: ChatInputCommandInteraction): Promise { + const user = b.user || b.member + if (!!user && user.id === adminId) { + await b.reply("Mmm... let's see... this goes here...") + const self = b.client.user + self.setPresence({ + status: "online", + afk: false, + activities: [ + { + type: ActivityType.Watching, + name: "Compilers at Work", + }, + ], + }) + try { + await new Promise((resolve, reject) => { + const result = spawn(resolvePath("./node_modules/.bin/tsc"), { + cwd: process.cwd(), + stdio: "inherit", + detached: true, + shell: true, + }) + result.on("error", (ex) => { + reject(ex) + }) + result.on("exit", (code) => { + if (code !== 0) { + reject(`Bad exit code ${code}`) + } else { + resolve() + } + }) + }) + } catch (ex) { + console.log(ex) + self.setPresence(defaultPresence) + await b.followUp("Oops... I think it's broke...") + return + } + self.setPresence({ + status: "idle", + afk: true, + activities: [ + { + type: ActivityType.Listening, + name: "Born-Again Bot Girl", + }, + ], + }) + await b.followUp("Phewwww... now I'll just... take a quick nap after all that hard work...") + await wrappedRestart(b) + } else { + await b.reply("Heeey... I don't gotta do what you say...") + } + } +} + +export const commandBotRebuild = new RebuildCommand() \ No newline at end of file diff --git a/src/commands/bot/restart.ts b/src/commands/bot/restart.ts new file mode 100644 index 0000000..7f7ee65 --- /dev/null +++ b/src/commands/bot/restart.ts @@ -0,0 +1,39 @@ +import {adminId, SubcommandData} from "../types.js" +import { + ActivityType, + ApplicationCommandOptionType, + ApplicationCommandSubCommandData, + ChatInputCommandInteraction, +} from "discord.js" +import {wrappedRestart} from "../../ipc/restart.js" + +class RestartCommand extends SubcommandData { + readonly definition: ApplicationCommandSubCommandData = { + name: "restart", + type: ApplicationCommandOptionType.Subcommand, + description: "Tells the bot to restart.", + } + + async execute(b: ChatInputCommandInteraction): Promise { + const user = b.user || b.member + if (!!user && user.id === adminId) { + await b.reply("Yaaaawwn... Okay... Just a quick nap then...") + const self = b.client.user + self.setPresence({ + status: "idle", + afk: true, + activities: [ + { + type: ActivityType.Listening, + name: "A New Day", + }, + ], + }) + await wrappedRestart(b) + } else { + await b.reply("Heeey... I don't gotta do what you say...") + } + } +} + +export const commandBotRestart = new RestartCommand() \ No newline at end of file diff --git a/src/commands/bot/shutdown.ts b/src/commands/bot/shutdown.ts new file mode 100644 index 0000000..a781b06 --- /dev/null +++ b/src/commands/bot/shutdown.ts @@ -0,0 +1,27 @@ +import {adminId, SubcommandData} from "../types.js" +import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" + +class ShutdownCommand extends SubcommandData { + readonly definition: ApplicationCommandSubCommandData = { + name: "shutdown", + type: ApplicationCommandOptionType.Subcommand, + description: "Tells the bot to shut down.", + } + + async execute(b: ChatInputCommandInteraction): Promise { + const user = b.user || b.member + if (!!user && user.id === adminId) { + await b.reply("Good night =w=") + const self = b.client.user + self.presence.set({ + status: "invisible", + activities: [], + }) + b.client.destroy() + } else { + await b.reply("Heeey... I don't gotta do what you say...") + } + } +} + +export const commandBotShutdown = new ShutdownCommand() \ No newline at end of file diff --git a/src/commands/character/create.ts b/src/commands/character/create.ts new file mode 100644 index 0000000..3c5a129 --- /dev/null +++ b/src/commands/character/create.ts @@ -0,0 +1,25 @@ +import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ChatInputCommandInteraction} from "discord.js" +import {SubcommandData} from "../types.js" + +class CharacterCreateCommandData extends SubcommandData { + readonly definition: ApplicationCommandSubCommandData = { + name: "create", + type: ApplicationCommandOptionType.Subcommand, + description: "Creates a new character. Activates them on the current server.", + options: [ + { + name: "template", + type: ApplicationCommandOptionType.String, + description: "Optionally, an existing character of yours to use as a template.", + autocomplete: true, + required: false, + }, + ], + } + + async execute(b: ChatInputCommandInteraction) { + await b.reply("Okaaaay, I'll make you a character ❤\n\nRight after this nap...") + } +} + +export const commandCharacterCreate = new CharacterCreateCommandData() \ No newline at end of file diff --git a/src/commands/character/index.ts b/src/commands/character/index.ts new file mode 100644 index 0000000..4d78658 --- /dev/null +++ b/src/commands/character/index.ts @@ -0,0 +1,135 @@ +import {ApplicationCommandOptionType, ApplicationCommandSubCommandData, ApplicationCommandType} from "discord.js" +import {commandCharacterCreate} from "./create.js" +import {BaseChatInputCommandData, CommandWithSubcommandsData} from "../types.js" + +class CharacterCommandData extends CommandWithSubcommandsData { + readonly baseDefinition: BaseChatInputCommandData = { + name: "character", + type: ApplicationCommandType.ChatInput, + description: "Commands to manage your characters.", + } + + readonly subcommands = [ + commandCharacterCreate, + ] +} + +export const commandCharacter = new CharacterCommandData() + +const otherSubcommands: ApplicationCommandSubCommandData[] = [ + { + name: "select", + type: ApplicationCommandOptionType.Subcommand, + description: "Selects (and activates, if necessary) a character for use with other commands used in this server.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to switch to. If not supplied, shows you the current selected character.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "activate", + type: ApplicationCommandOptionType.Subcommand, + description: "Allows a character to be targeted on the current server.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to activate on this server.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "deactivate", + type: ApplicationCommandOptionType.Subcommand, + description: "Prevents a character from being targeted on the current server. Deselects them if they're selected.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to deactivate on this server.", + }, + ], + }, + { + name: "edit", + type: ApplicationCommandOptionType.Subcommand, + description: "Edits an existing character. Note that characters currently in battle cannot be edited.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "An existing character of yours to edit. Omit to edit the current character.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "list", + type: ApplicationCommandOptionType.Subcommand, + description: "Lists your characters.", + options: [ + { + name: "public", + type: ApplicationCommandOptionType.Boolean, + description: "True to share your character list with the current channel.", + required: false, + }, + ], + }, + { + name: "show", + type: ApplicationCommandOptionType.Subcommand, + description: "Shows the data about one of your characters.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "An existing character of yours to display in this channel. Omit to display the current character.", + autocomplete: true, + required: false, + }, + { + name: "public", + type: ApplicationCommandOptionType.Boolean, + description: "True to share your character list with the current channel.", + required: false, + }, + ], + }, + { + name: "archive", + type: ApplicationCommandOptionType.Subcommand, + description: "Removes a character from all servers and hides it from the default list view.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "An existing unarchived character of yours to add to the archive.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "unarchive", + type: ApplicationCommandOptionType.Subcommand, + description: "Unarchives a character.", + options: [ + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "An existing archived character of yours to take back out of the archive.", + autocomplete: true, + required: false, + }, + ], + }, +] \ No newline at end of file diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..41c7b95 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,356 @@ +import { + ApplicationCommand, + ApplicationCommandDataResolvable, + ApplicationCommandOptionType, + ApplicationCommandType, + ChatInputCommandInteraction, + Collection, +} from "discord.js" +import {commandCharacter} from "./character/index.js" +import {CommandData} from "./types.js" +import {commandBot} from "./bot/index.js" + +const commands: CommandData[] = [ + commandCharacter, + commandBot, +] + +export const commandDefinitions: ApplicationCommandDataResolvable[] = commands.map((c) => c.definition) + +const _commandNameCache: Record = Object.fromEntries( + commands.map((c) => [c.definition.name, c]), +) + +export const invalidCommandError = "No command by that name exists." + +export function storeCachedCommands(data: Collection) { + for (const command of data.values()) { + if (_commandNameCache.hasOwnProperty(command.name)) { + _commandNameCache[command.name].setCached(command) + } else { + console.log("No such command when caching commands: " + command.name) + } + } +} + +export async function executeCommand(ev: ChatInputCommandInteraction) { + const name = ev.commandName + if (!_commandNameCache.hasOwnProperty(name)) { + throw invalidCommandError + } + await _commandNameCache[name].execute(ev) +} + +const otherCommands: ApplicationCommandDataResolvable[] = [ + { + name: "playstyle", + type: ApplicationCommandType.ChatInput, + description: "Updates your play style.", + options: [ + { + name: "difficulty", + type: ApplicationCommandOptionType.Subcommand, + description: "Alters or displays the reformation difficulty of your currently selected character.", + }, + { + name: "preference", + type: ApplicationCommandOptionType.Subcommand, + description: "Alters or displays the pred/prey preference of your currently selected character.", + }, + ], + }, + { + name: "team", + type: ApplicationCommandType.ChatInput, + description: "Affects teams in battle.", + options: [ + { + name: "join", + type: ApplicationCommandOptionType.Subcommand, + description: "Leaves the current team, if any, and requests to join the same team (and battle) as another player.", + }, + { + name: "leave", + type: ApplicationCommandOptionType.Subcommand, + description: "Leaves the current team, if any, and strikes it out alone.", + }, + ], + }, + { + name: "battle", + type: ApplicationCommandType.ChatInput, + description: "Takes actions in battle.", + options: [ + { + name: "challenge", + type: ApplicationCommandOptionType.Subcommand, + description: "Challenges another character to a battle.", + options: [ + { + name: "target", + type: ApplicationCommandOptionType.String, + description: "The character you want to challenge.", + autocomplete: true, + }, + ], + }, + { + name: "menu", + type: ApplicationCommandOptionType.Subcommand, + description: "Opens the battle menu to select an action for this turn.", + }, + { + name: "skill", + type: ApplicationCommandOptionType.Subcommand, + description: "Uses a skill.", + options: [ + { + name: "name", + type: ApplicationCommandOptionType.String, + description: "The name of the skill to use. Omit to see a list.", + autocomplete: true, + required: false, + }, + { + name: "target", + type: ApplicationCommandOptionType.String, + description: "The target to use the skill on, if there are multiple options. Omit to see a list.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "item", + type: ApplicationCommandOptionType.Subcommand, + description: "Uses an item.", + options: [ + { + name: "name", + type: ApplicationCommandOptionType.String, + description: "The name of the item to use. Omit to see a list.", + autocomplete: true, + required: false, + }, + { + name: "target", + type: ApplicationCommandOptionType.String, + description: "The target to use the item on, if there are multiple options. Omit to see a list.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "vore", + type: ApplicationCommandOptionType.Subcommand, + description: "Devours your opponent. Requires your opponent to be at low Confidence.", + options: [ + { + name: "style", + type: ApplicationCommandOptionType.String, + description: "The style of devouring to use. Omit to see a list.", + autocomplete: true, + required: false, + }, + ], + }, + { + name: "rest", + type: ApplicationCommandOptionType.Subcommand, + description: "Takes a breather, recovering your Confidence and Energy at a faster-than-normal rate for this turn.", + }, + { + name: "escape", + type: ApplicationCommandOptionType.Subcommand, + description: "Tries to escape from a battle or stomach. Requires your opponent to be at low Confidence.", + }, + { + name: "surrender", + type: ApplicationCommandOptionType.Subcommand, + description: "Lets your opponent do as they will with you by reducing your Confidence or Health to 0.", + }, + { + name: "prey", + type: ApplicationCommandOptionType.SubcommandGroup, + description: "Commands for when you have a full belly.", + options: [ + { + name: "release", + type: ApplicationCommandOptionType.Subcommand, + description: "Releases your prey from your stomach.", + }, + { + name: "squish", + type: ApplicationCommandOptionType.Subcommand, + description: "Squishes your prey, ending the combat. Can only be performed when the prey is at or below 0% Health.", + }, + ], + }, + ], + }, + { + name: "safety", + type: ApplicationCommandType.ChatInput, + description: "Safety commands for protecting consent and user data.", + options: [ + { + name: "exit", + type: ApplicationCommandOptionType.SubcommandGroup, + description: "Exits battle(s) and reverts its/their effects on you. To flee in-character, use /battle escape.", + }, + { + name: "block", + type: ApplicationCommandOptionType.SubcommandGroup, + description: "Manages your block list.", + options: [ + { + name: "add", + type: ApplicationCommandOptionType.Subcommand, + description: "Prevents a user or character from challenging you or joining battles you're in.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to block from challenging you or joining battles you're in.", + required: false, + }, + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to block from challenging you or joining battles you're in.", + required: false, + autocomplete: true, + }, + ], + }, + { + name: "remove", + type: ApplicationCommandOptionType.Subcommand, + description: "Allows a user or character to challenge you and join battles you're in once more.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to allow to challenge you and join battles you're in once more.", + required: false, + }, + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to allow to challenge you and join battles you're in once more.", + required: false, + autocomplete: true, + }, + ], + }, + { + name: "list", + type: ApplicationCommandOptionType.Subcommand, + description: "Displays your current block list.", + }, + ], + }, + { + name: "kick", + type: ApplicationCommandOptionType.SubcommandGroup, + description: "Manages the list of users kicked from the current battle.", + options: [ + { + name: "add", + type: ApplicationCommandOptionType.Subcommand, + description: "Removes a user or character from the current battle and prevents them from rejoining.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to remove from the current battle and prevent from rejoining.", + required: false, + }, + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The character to remove from the current battle and prevent from rejoining.", + required: false, + autocomplete: true, + }, + ], + }, + { + name: "remove", + type: ApplicationCommandOptionType.Subcommand, + description: "Allows a user or character to rejoin the current battle.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to allow to rejoin the current battle.", + required: false, + }, + { + name: "character", + type: ApplicationCommandOptionType.String, + description: "The user to allow to rejoin the current battle.", + required: false, + autocomplete: true, + }, + ], + }, + { + name: "list", + type: ApplicationCommandOptionType.Subcommand, + description: "Displays the list of users forbidden from rejoining the current battle.", + }, + ], + }, + { + name: "ban", + type: ApplicationCommandOptionType.SubcommandGroup, + description: "Manages the ban list of the current server.", + options: [ + { + name: "add", + type: ApplicationCommandOptionType.Subcommand, + description: "Bans a user from participating in battles in the current server and kicks them from any they're in.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to ban from participating in battles in the current server.", + required: true, + }, + ], + }, + { + name: "remove", + type: ApplicationCommandOptionType.Subcommand, + description: "Allows a user to resume participating in battles in the current server.", + options: [ + { + name: "user", + type: ApplicationCommandOptionType.User, + description: "The user to allow to participate in battles in the current server once more.", + required: true, + }, + ], + }, + { + name: "list", + type: ApplicationCommandOptionType.Subcommand, + description: "Displays this server's current ban list.", + }, + ], + }, + { + name: "delete", + type: ApplicationCommandOptionType.Subcommand, + description: "PERMANENTLY deletes a character, exiting any battles they were part of. See also /character archive.", + }, + { + name: "wipe", + type: ApplicationCommandOptionType.Subcommand, + description: "PERMANENTLY deletes ALL data about you in the system, erasing you from the system's knowledge.", + }, + ], + }, +] \ No newline at end of file diff --git a/src/commands/types.ts b/src/commands/types.ts new file mode 100644 index 0000000..1bc8698 --- /dev/null +++ b/src/commands/types.ts @@ -0,0 +1,154 @@ +import { + ApplicationCommand, + ApplicationCommandSubCommandData, + ApplicationCommandSubGroupData, + ChatInputApplicationCommandData, + ChatInputCommandInteraction, +} from "discord.js" + +export const noSubcommandError = "No subcommand was provided, but one is required." +export const invalidSubcommandGroupError = "The subcommand group provided does not exist." +export const invalidSubcommandError = "The subcommand provided does not exist." + +export const adminId = "126936953789743104" + +export abstract class CommandData { + private _cachedGlobal: ApplicationCommand | null = null + private _cacheByGuild: Record = {} + + abstract get definition(): ChatInputApplicationCommandData + + getCached(guildId: string | null): ApplicationCommand { + if (guildId === null) { + if (this._cachedGlobal === null) { + throw Error("Not yet cached") + } + return this._cachedGlobal + } else if (this._cacheByGuild.hasOwnProperty(guildId)) { + return this._cacheByGuild[guildId] + } else { + throw Error("Not yet cached") + } + } + + setCached(command: ApplicationCommand) { + if (command.guildId === null) { + this._cachedGlobal = command + } else { + this._cacheByGuild[command.guildId] = command + } + } + + abstract execute(b: ChatInputCommandInteraction): Promise +} + +export type BaseChatInputCommandData = Omit + +export abstract class CommandWithSubcommandsData extends CommandData { + private _subcommandGroupsCache: Record | null = null + private _subcommandsCache: Record | null = null + + get definition(): ChatInputApplicationCommandData { + return { + options: this.subcommands.map((s) => s.definition), + ...this.baseDefinition, + } + } + + protected abstract get baseDefinition(): BaseChatInputCommandData + + protected abstract get subcommands(): (SubcommandData | SubcommandGroupData)[] + + async execute(b: ChatInputCommandInteraction) { + const group = b.options.getSubcommandGroup() + const subcommand = b.options.getSubcommand() + if (group !== null) { + await this.resolveSubcommandGroup(group).execute(b) + } else if (subcommand !== null) { + await this.resolveSubcommand(subcommand).execute(b) + } else { + throw noSubcommandError + } + } + + protected resolveSubcommandGroup(name: string): SubcommandGroupData { + let cache = this._subcommandGroupsCache + if (cache === null) { + cache = {} + for (const item of this.subcommands) { + if (item instanceof SubcommandGroupData) { + cache[item.definition.name] = item + } + } + this._subcommandGroupsCache = cache + } + if (!cache.hasOwnProperty(name)) { + throw invalidSubcommandGroupError + } + return cache[name] + } + + protected resolveSubcommand(name: string): SubcommandData { + let cache = this._subcommandsCache + if (cache === null) { + cache = {} + for (const item of this.subcommands) { + if (item instanceof SubcommandData) { + cache[item.definition.name] = item + } + } + this._subcommandsCache = cache + } + if (!cache.hasOwnProperty(name)) { + throw invalidSubcommandGroupError + } + return cache[name] + } +} + +export type BaseSubcommandGroupData = Omit + +export abstract class SubcommandGroupData { + private _subcommandsCache: Record | null = null + + get definition(): ApplicationCommandSubGroupData { + return { + ...this.baseDefinition, + options: this.subcommands.map((s) => s.definition), + } + } + + protected abstract get baseDefinition(): BaseSubcommandGroupData + + protected abstract get subcommands(): SubcommandData[] + + async execute(b: ChatInputCommandInteraction): Promise { + const subcommand = b.options.getSubcommand() + if (subcommand !== null) { + await this.resolveSubcommand(subcommand).execute(b) + } else { + throw noSubcommandError + } + } + + protected resolveSubcommand(name: string): SubcommandData { + let cache = this._subcommandsCache + if (cache === null) { + cache = {} + for (const item of this.subcommands) { + cache[item.definition.name] = item + } + this._subcommandsCache = cache + } + if (!cache.hasOwnProperty(name)) { + throw invalidSubcommandError + } + return cache[name] + } +} + +export abstract class SubcommandData { + abstract get definition(): ApplicationCommandSubCommandData + + abstract execute(b: ChatInputCommandInteraction): Promise +} \ No newline at end of file diff --git a/src/defaultPresence.ts b/src/defaultPresence.ts new file mode 100644 index 0000000..0b8af7d --- /dev/null +++ b/src/defaultPresence.ts @@ -0,0 +1,11 @@ +import {ActivityType, PresenceData} from "discord.js" + +export const defaultPresence: PresenceData = { + status: "online", + activities: [ + { + name: "Vore PvP", + type: ActivityType.Playing, + }, + ], +} \ No newline at end of file diff --git a/src/ipc/restart.ts b/src/ipc/restart.ts new file mode 100644 index 0000000..b5ed282 --- /dev/null +++ b/src/ipc/restart.ts @@ -0,0 +1,200 @@ +import {fork} from "child_process" +import {ChatInputCommandInteraction, Client, InteractionWebhook} from "discord.js" + +export enum RestartReason { + RestartCommand = "restart", +} + +export enum StartState { + NeverStarted = "never_started", + Errored = "errored", + Crashed = "crashed", + Started = "started", + Ready = "ready", + Failed = "failed", +} + +export interface StartData { + state: StartState, + status?: string +} + +export interface RestartData { + appId: string + token: string + reason: RestartReason +} + +let restartState: RestartData | null = null + +export async function checkIsRestart(): Promise { + if (!process.connected) { + return null + } else { + try { + const result = await new Promise((resolve) => { + process.on("message", (value: { restart?: RestartData }) => { + if (value && value.restart) { + resolve(value.restart) + } else { + resolve(null) + } + }) + setTimeout(() => resolve(null), 50) + }) + if (result !== null) { + restartState = result + } + return result + } catch (ex) { + return null + } + } +} + +export async function reportStarted(): Promise { + if (!process.send) { + return + } + const startData: StartData = { + state: StartState.Started, + } + process.send(startData) +} + +export async function reportReady(c: Client): Promise { + if (restartState) { + try { + const hook = new InteractionWebhook(c, restartState.appId, restartState.token) + await hook.send({ + content: "yawwwwn... Good morning...", + }) + } catch (ex) { + console.log("followup failed to send", ex) + } + } + if (!process.send) { + return false + } + const startData: StartData = { + state: StartState.Ready, + } + process.send(startData) + return true +} + +export async function reportFailed(c: Client, error: unknown): Promise { + console.log("failed to start", error) + if (restartState) { + try { + const hook = new InteractionWebhook(c, restartState.appId, restartState.token) + await hook.send({ + content: "Ugh... I can't get up... Head hurts... sorry...", + }) + } catch (ex) { + console.log("followup failed to send", ex) + } + } + if (!process.send) { + return + } + const startData: StartData = { + state: StartState.Failed, + status: `${error}`, + } + process.send(startData) +} + +export async function wrappedRestart(b: ChatInputCommandInteraction) { + try { + await doRestart(b.client, b.webhook.id, b.webhook.token, RestartReason.RestartCommand) + } catch (ex) { + console.log("failed doing restart", ex) + let data: StartData | null = null + if (typeof ex === "object" && ex !== null && ex.hasOwnProperty("state")) { + data = ex as StartData + } + if ((data && data.state)) { + try { + b.client.user.presence.set({ + status: "invisible", + activities: [], + }) + await b.followUp("Mhhhh... I don't feel good...") + } catch (ex) { + console.log("failed resetting presence and sending followup", ex) + } + } + } finally { + b.client.destroy() + } +} + +export async function doRestart(client: Client, appId: string, token: string, + restartReason: RestartReason): Promise { + return new Promise((resolve, reject) => { + const child = fork(process.argv[1], process.argv.slice(2), { + execPath: process.execPath, + cwd: process.cwd(), + detached: true, + stdio: "inherit", + }) + let result: StartData = { + state: StartState.NeverStarted, + } + child.once("error", (err) => { + result.state = StartState.Errored + result.status = `${err}` + if (child.connected) { + child.unref() + child.disconnect() + } + reject({...result}) + }) + child.once("exit", (code, signal) => { + result.state = StartState.Crashed + result.status = `Exited unexpectedly with code ${code} (signal ${signal})` + if (child.connected) { + child.unref() + child.disconnect() + } + reject({...result}) + }) + child.on("message", (message: StartData) => { + switch (typeof message === "object" && message.state) { + case StartState.Started: + result.state = StartState.Started + break + case StartState.Ready: + resolve({...message}) + if (child.connected) { + child.unref() + child.disconnect() + } + break + case StartState.Failed: + reject({...message}) + if (child.connected) { + child.unref() + child.disconnect() + } + break + default: + result.state = StartState.Failed + result.status = `Unexpected message: ${message}` + reject({...message}) + if (child.connected) { + child.unref() + child.disconnect() + } + break + } + }) + process.on("disconnect", () => { + result.state = StartState.Crashed + reject({...result}) + child.unref() + }) + child.send({restart: {appId, token, reason: restartReason}}) + }) +} \ No newline at end of file diff --git a/src/main.spec.ts b/src/main.spec.ts index 7dec5e9..723144b 100644 --- a/src/main.spec.ts +++ b/src/main.spec.ts @@ -1,7 +1,7 @@ -import { describe, test, expect } from '@jest/globals' +import {describe, expect, test} from "@jest/globals" describe("Jest testing", () => { - test("runs", () => { - expect(test).toBeDefined() - }) + test("runs", () => { + expect(test).toBeDefined() + }) }) diff --git a/src/main.ts b/src/main.ts index c546995..478b858 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,496 +1,100 @@ -import { - ApplicationCommandDataResolvable, - ApplicationCommandOptionType, - ApplicationCommandType, - Client, - GatewayIntentBits -} from 'discord.js' -import {config} from 'dotenv' - -const commands: ApplicationCommandDataResolvable[] = [ - { - name: "character", - type: ApplicationCommandType.ChatInput, - description: "Commands to manage your characters.", - options: [ - { - name: "create", - type: ApplicationCommandOptionType.Subcommand, - description: "Creates a new character. Activates them on the current server.", - options: [ - { - name: "template", - type: ApplicationCommandOptionType.String, - description: "Optionally, an existing character of yours to use as a template.", - autocomplete: true, - required: false, - }, - ], - }, - { - name: "select", - type: ApplicationCommandOptionType.Subcommand, - description: "Selects (and activates, if necessary) a character for use with other commands used in this server.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to switch to. If not supplied, shows you the current selected character.", - autocomplete: true, - required: false, - }, - ], - }, - { - name: "activate", - type: ApplicationCommandOptionType.Subcommand, - description: "Allows a character to be targeted on the current server.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to activate on this server.", - autocomplete: true, - required: false, - }, - ] - }, - { - name: "deactivate", - type: ApplicationCommandOptionType.Subcommand, - description: "Prevents a character from being targeted on the current server. Deselects them if they're selected.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to deactivate on this server." - } - ] - }, - { - name: "edit", - type: ApplicationCommandOptionType.Subcommand, - description: "Edits an existing character. Note that characters currently in battle cannot be edited.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "An existing character of yours to edit. Omit to edit the current character.", - autocomplete: true, - required: false, - }, - ], - }, - { - name: "list", - type: ApplicationCommandOptionType.Subcommand, - description: "Lists your characters.", - options: [ - { - name: "public", - type: ApplicationCommandOptionType.Boolean, - description: "True to share your character list with the current channel.", - required: false, - } - ], - }, - { - name: "show", - type: ApplicationCommandOptionType.Subcommand, - description: "Shows the data about one of your characters.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "An existing character of yours to display in this channel. Omit to display the current character.", - autocomplete: true, - required: false, - }, - { - name: "public", - type: ApplicationCommandOptionType.Boolean, - description: "True to share your character list with the current channel.", - required: false, - } - ], - }, - { - name: "archive", - type: ApplicationCommandOptionType.Subcommand, - description: "Removes a character from all servers and hides it from the default list view.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "An existing unarchived character of yours to add to the archive.", - autocomplete: true, - required: false, - }, - ], - }, - { - name: "unarchive", - type: ApplicationCommandOptionType.Subcommand, - description: "Unarchives a character.", - options: [ - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "An existing archived character of yours to take back out of the archive.", - autocomplete: true, - required: false, - }, - ], - }, - ], - }, - { - name: "playstyle", - type: ApplicationCommandType.ChatInput, - description: "Updates your play style.", - options: [ - { - name: "difficulty", - type: ApplicationCommandOptionType.Subcommand, - description: "Alters or displays the reformation difficulty of your currently selected character." - }, - { - name: "preference", - type: ApplicationCommandOptionType.Subcommand, - description: "Alters or displays the pred/prey preference of your currently selected character." - } - ] - }, - { - name: "team", - type: ApplicationCommandType.ChatInput, - description: "Affects teams in battle.", - options: [ - { - name: "join", - type: ApplicationCommandOptionType.Subcommand, - description: "Leaves the current team, if any, and requests to join the same team (and battle) as another player." - }, - { - name: "leave", - type: ApplicationCommandOptionType.Subcommand, - description: "Leaves the current team, if any, and strikes it out alone." - }, - ], - }, - { - name: "battle", - type: ApplicationCommandType.ChatInput, - description: "Takes actions in battle.", - options: [ - { - name: "challenge", - type: ApplicationCommandOptionType.Subcommand, - description: "Challenges another character to a battle.", - options: [ - { - name: "target", - type: ApplicationCommandOptionType.String, - description: "The character you want to challenge.", - autocomplete: true, - } - ] - }, - { - name: "menu", - type: ApplicationCommandOptionType.Subcommand, - description: "Opens the battle menu to select an action for this turn.", - }, - { - name: "skill", - type: ApplicationCommandOptionType.Subcommand, - description: "Uses a skill.", - options: [ - { - name: "name", - type: ApplicationCommandOptionType.String, - description: "The name of the skill to use. Omit to see a list.", - autocomplete: true, - required: false, - }, - { - name: "target", - type: ApplicationCommandOptionType.String, - description: "The target to use the skill on, if there are multiple options. Omit to see a list.", - autocomplete: true, - required: false, - }, - ], - }, - { - name: "item", - type: ApplicationCommandOptionType.Subcommand, - description: "Uses an item.", - options: [ - { - name: "name", - type: ApplicationCommandOptionType.String, - description: "The name of the item to use. Omit to see a list.", - autocomplete: true, - required: false, - }, - { - name: "target", - type: ApplicationCommandOptionType.String, - description: "The target to use the item on, if there are multiple options. Omit to see a list.", - autocomplete: true, - required: false, - }, - ] - }, - { - name: "vore", - type: ApplicationCommandOptionType.Subcommand, - description: "Devours your opponent. Requires your opponent to be at low Confidence.", - options: [ - { - name: "style", - type: ApplicationCommandOptionType.String, - description: "The style of devouring to use. Omit to see a list.", - autocomplete: true, - required: false, - }, - ] - }, - { - name: "rest", - type: ApplicationCommandOptionType.Subcommand, - description: "Takes a breather, recovering your Confidence and Energy at a faster-than-normal rate for this turn." - }, - { - name: "escape", - type: ApplicationCommandOptionType.Subcommand, - description: "Tries to escape from a battle or stomach. Requires your opponent to be at low Confidence." - }, - { - name: "surrender", - type: ApplicationCommandOptionType.Subcommand, - description: "Lets your opponent do as they will with you by reducing your Confidence or Health to 0." - }, - { - name: "prey", - type: ApplicationCommandOptionType.SubcommandGroup, - description: "Commands for when you have a full belly.", - options: [ - { - name: "release", - type: ApplicationCommandOptionType.Subcommand, - description: "Releases your prey from your stomach." - }, - { - name: "squish", - type: ApplicationCommandOptionType.Subcommand, - description: "Squishes your prey, ending the combat. Can only be performed when the prey is at or below 0% Health." - }, - ], - }, - ], - }, - { - name: "safety", - type: ApplicationCommandType.ChatInput, - description: "Safety commands for protecting consent and user data.", - options: [ - { - name: "exit", - type: ApplicationCommandOptionType.SubcommandGroup, - description: "Exits battle(s) and reverts its/their effects on you. To flee in-character, use /battle escape.", - }, - { - name: "block", - type: ApplicationCommandOptionType.SubcommandGroup, - description: "Manages your block list.", - options: [ - { - name: "add", - type: ApplicationCommandOptionType.Subcommand, - description: "Prevents a user or character from challenging you or joining battles you're in.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to block from challenging you or joining battles you're in.", - required: false - }, - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to block from challenging you or joining battles you're in.", - required: false, - autocomplete: true - }, - ], - }, - { - name: "remove", - type: ApplicationCommandOptionType.Subcommand, - description: "Allows a user or character to challenge you and join battles you're in once more.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to allow to challenge you and join battles you're in once more.", - required: false - }, - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to allow to challenge you and join battles you're in once more.", - required: false, - autocomplete: true - }, - ], - }, - { - name: "list", - type: ApplicationCommandOptionType.Subcommand, - description: "Displays your current block list." - }, - ], - }, - { - name: "kick", - type: ApplicationCommandOptionType.SubcommandGroup, - description: "Manages the list of users kicked from the current battle.", - options: [ - { - name: "add", - type: ApplicationCommandOptionType.Subcommand, - description: "Removes a user or character from the current battle and prevents them from rejoining.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to remove from the current battle and prevent from rejoining.", - required: false - }, - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The character to remove from the current battle and prevent from rejoining.", - required: false, - autocomplete: true - }, - ], - }, - { - name: "remove", - type: ApplicationCommandOptionType.Subcommand, - description: "Allows a user or character to rejoin the current battle.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to allow to rejoin the current battle.", - required: false - }, - { - name: "character", - type: ApplicationCommandOptionType.String, - description: "The user to allow to rejoin the current battle.", - required: false, - autocomplete: true - }, - ], - }, - { - name: "list", - type: ApplicationCommandOptionType.Subcommand, - description: "Displays the list of users forbidden from rejoining the current battle." - }, - ], - }, - { - name: "ban", - type: ApplicationCommandOptionType.SubcommandGroup, - description: "Manages the ban list of the current server.", - options: [ - { - name: "add", - type: ApplicationCommandOptionType.Subcommand, - description: "Bans a user from participating in battles in the current server and kicks them from any they're in.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to ban from participating in battles in the current server.", - required: true - }, - ], - }, - { - name: "remove", - type: ApplicationCommandOptionType.Subcommand, - description: "Allows a user to resume participating in battles in the current server.", - options: [ - { - name: "user", - type: ApplicationCommandOptionType.User, - description: "The user to allow to participate in battles in the current server once more.", - required: true - }, - ], - }, - { - name: "list", - type: ApplicationCommandOptionType.Subcommand, - description: "Displays this server's current ban list." - }, - ], - }, - { - name: "delete", - type: ApplicationCommandOptionType.Subcommand, - description: "PERMANENTLY deletes a character, exiting any battles they were part of. See also /character archive.", - }, - { - name: "wipe", - type: ApplicationCommandOptionType.Subcommand, - description: "PERMANENTLY deletes ALL data about you in the system, erasing you from the system's knowledge.", - }, - ], - } -] +import {BaseInteraction, Client} from "discord.js" +import {config} from "dotenv" +import {isChatInputCommand} from "./types/interactions.js" +import {commandDefinitions, executeCommand, storeCachedCommands} from "./commands/index.js" +import {checkIsRestart, reportFailed, reportReady, reportStarted} from "./ipc/restart.js" +import {defaultPresence} from "./defaultPresence.js" async function main() { - config() - const c = new Client({ - intents: [], - }) - c.on('ready', async () => { - const app = c.application - if (!c.application) { - console.log("Bot is ready, but no application") - } else { - try { - await c.application.commands.set(commands) - console.log("Commands established") - } catch (ex) { - console.log("Bot is ready, but setting commands failed: " + ex) - } - } - }) - c.on('error', async (ex) => { - console.log("Connection error: " + ex) - }) - c.on('interactionCreate', async (ev) => { - if (ev.isRepliable()) { - try { - await ev.reply("Huuuuuuuh? ... I don't know what to do with that yet.") - console.log("answered an interaction") - } catch (ex) { - console.log("failed answering an interaction: " + ex) - } - } else { - console.log("got an interaction but can't reply to it") - } - }) - await c.login(process.env.DISCORD_TOKEN || "") + await checkIsRestart() + config() + const c = new Client({ + intents: [], + }) + c.on("ready", async () => { + const app = c.application + if (!app) { + c.destroy() + await reportFailed(c, "No application was given") + return + } else { + try { + storeCachedCommands(await app.commands.set(commandDefinitions)) + const g = await c.guilds.fetch() + for (const guild of g.values()) { + storeCachedCommands(await app.commands.set(commandDefinitions, guild.id)) + } + } catch (ex) { + c.destroy() + await reportFailed(c, ex) + return + } + } + const user = c.user + if (!user) { + c.destroy() + await reportFailed(c, "No user found") + return + } else { + user.setPresence(defaultPresence) + } + try { + await reportReady(c) + } catch (ex) { + console.log(ex) + } + }) + c.on("error", async (ex) => { + c.destroy() + await reportFailed(c, ex) + }) + c.on("interactionCreate", async (ev: BaseInteraction) => { + if (ev.client.user.presence.status !== "online") { + if (ev.isRepliable()) { + try { + await ev.reply({ + content: "Shhh... I'm sleeping!! Try again later.", + ephemeral: true, + }) + } catch (ex) { + console.log("failed sending busy reply", ex) + } + } + return + } + if (isChatInputCommand(ev)) { + try { + await executeCommand(ev) + } catch (ex) { + console.log("failed executing command", ev, ex) + if (!ev.replied) { + try { + await ev.reply({ + ephemeral: true, + content: "Uuguuu... I can't think straight... try again later, 'kay?", + }) + } catch (innerEx) { + console.log("failed sending error response", innerEx) + } + } + } + } else if (ev.isRepliable()) { + try { + await ev.reply({ + ephemeral: true, + content: "Huuuuuuuh? ... I don't know what to do with that yet.", + }) + } catch (ex) { + console.log("failed sending unknown command response", ex) + } + } else { + console.log("got an interaction but can't reply to it") + } + }) + await reportStarted() + await c.login(process.env.DISCORD_TOKEN || "") } -main().catch((ex) => {console.log("main thread failed: " + ex)}) \ No newline at end of file +main().catch((ex) => { + console.log("main thread failed", ex) +}) \ No newline at end of file diff --git a/src/types/interactions.ts b/src/types/interactions.ts new file mode 100644 index 0000000..c5882c0 --- /dev/null +++ b/src/types/interactions.ts @@ -0,0 +1,5 @@ +import {BaseInteraction, ChatInputCommandInteraction} from "discord.js" + +export function isChatInputCommand(x: BaseInteraction): x is ChatInputCommandInteraction { + return x.isChatInputCommand() +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 43e0999..9406ee2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,8 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": false, - "outDir": "build" + "outDir": "build", + "sourceMap": true }, "include": [ "src"