From bf280752cfdcf59ad172db24ea14b5254ead86ff Mon Sep 17 00:00:00 2001
From: kazukazu123123 <50506519+kazukazu123123@users.noreply.github.com>
Date: Tue, 30 Jul 2024 18:03:42 +0900
Subject: [PATCH] feat: prevent timeout in command response (#984)

Co-authored-by: Mogyuchi <mogyuchi@mogyuchi.jp>
---
 src/commands/join.ts         | 15 +++++++++++----
 src/commands/leave.ts        | 21 ++++++++++++++++-----
 src/commands/read.ts         |  7 +++++--
 src/commands/rejoin.ts       |  7 +++++--
 src/commands/reset.ts        |  5 +++--
 src/commands/userSettings.ts |  9 +++++++--
 src/connectionCtx.ts         | 17 +++++++++++------
 src/guildCtx.ts              |  8 ++++++--
 src/utils.ts                 | 14 +++++++++++++-
 9 files changed, 77 insertions(+), 26 deletions(-)

diff --git a/src/commands/join.ts b/src/commands/join.ts
index e482895c..e01c097b 100644
--- a/src/commands/join.ts
+++ b/src/commands/join.ts
@@ -3,7 +3,7 @@ import { guildCtxManager } from '../index.js'
 import { type InteractionReplyOptions } from 'discord.js'
 import { checkCanJoin, checkUserAlreadyJoined } from '../components/preCheck.js'
 import { newGuildTextBasedChannelId, newVoiceBasedChannelId } from '../id.js'
-import { getErrorReply } from '../utils.js'
+import { deferredReplyOrEdit, getErrorReply } from '../utils.js'
 
 export class JoinCommand extends Command {
   public constructor(
@@ -48,10 +48,17 @@ export class JoinCommand extends Command {
       checkCanJoin(voiceChannel)
 
       const guildCtx = guildCtxManager.get(interaction.member.guild)
+      const readChannelId = newGuildTextBasedChannelId(interaction.channel)
+      const voiceChannelId = newVoiceBasedChannelId(voiceChannel)
+
+      guildCtx.checkAlreadyJoined(voiceChannelId)
+      guildCtx.connectionManager.checkAlreadyUsedChannel(readChannelId)
+
+      await interaction.deferReply()
 
       const worker = await guildCtx.join({
-        voiceChannelId: newVoiceBasedChannelId(voiceChannel),
-        readChannelId: newGuildTextBasedChannelId(interaction.channel),
+        voiceChannelId,
+        readChannelId,
       })
 
       interactionReplyOptions = {
@@ -73,7 +80,7 @@ export class JoinCommand extends Command {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/commands/leave.ts b/src/commands/leave.ts
index 5aaeb836..9f9f20e4 100644
--- a/src/commands/leave.ts
+++ b/src/commands/leave.ts
@@ -4,7 +4,11 @@ import { checkUserAlreadyJoined } from '../components/preCheck.js'
 import type { InteractionReplyOptions } from 'discord.js'
 import { LeaveCause } from '../connectionCtx.js'
 import { newGuildTextBasedChannelId, newVoiceBasedChannelId } from '../id.js'
-import { getErrorReply } from '../utils.js'
+import { deferredReplyOrEdit, getErrorReply } from '../utils.js'
+import {
+  HandleInteractionError,
+  HandleInteractionErrorType,
+} from '../errors/index.js'
 
 export class LeaveCommand extends Command {
   public constructor(
@@ -48,11 +52,18 @@ export class LeaveCommand extends Command {
       checkUserAlreadyJoined(voiceChannel)
 
       const ctx = guildCtxManager.get(interaction.member.guild)
-      const textChannelId = ctx.connectionManager.channelMap.get(
+      const readChannelId = ctx.connectionManager.channelMap.get(
         newVoiceBasedChannelId(voiceChannel),
       )
+      if (readChannelId === undefined)
+        throw new HandleInteractionError(
+          HandleInteractionErrorType.userNotWithBot,
+        )
+
+      await interaction.deferReply()
+
       const cause =
-        newGuildTextBasedChannelId(interaction.channel) === textChannelId
+        newGuildTextBasedChannelId(interaction.channel) === readChannelId
           ? undefined
           : LeaveCause.command
       const workerId = await ctx.leave({
@@ -67,7 +78,7 @@ export class LeaveCommand extends Command {
             title: 'ボイスチャンネルから退出しました。',
             description: [
               `担当BOT: <@${workerId}>`,
-              `テキストチャンネル: <#${textChannelId}>`,
+              `テキストチャンネル: <#${readChannelId}>`,
               `ボイスチャンネル: ${voiceChannel}`,
               'またのご利用をお待ちしております。',
             ].join('\n'),
@@ -78,7 +89,7 @@ export class LeaveCommand extends Command {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/commands/read.ts b/src/commands/read.ts
index cf5b6f36..23b253c1 100644
--- a/src/commands/read.ts
+++ b/src/commands/read.ts
@@ -13,7 +13,7 @@ import {
 } from '../errors/index.js'
 import { checkUserAlreadyJoined } from '../components/preCheck.js'
 import { newVoiceBasedChannelId } from '../id.js'
-import { getErrorReply } from '../utils.js'
+import { deferredReplyOrEdit, getErrorReply } from '../utils.js'
 
 export class ReadCommand extends Command {
   public constructor(
@@ -118,6 +118,7 @@ export class ReadCommand extends Command {
 
     try {
       checkUserAlreadyJoined(voiceChannel)
+
       const text = interaction.options.getString('text', true)
 
       const connectionCtx = guildCtxManager
@@ -130,6 +131,8 @@ export class ReadCommand extends Command {
           HandleInteractionErrorType.userNotWithBot,
         )
 
+      await interaction.deferReply()
+
       const convertedMessage = convertContent(
         text,
         [],
@@ -152,7 +155,7 @@ export class ReadCommand extends Command {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/commands/rejoin.ts b/src/commands/rejoin.ts
index f89f67b1..a6a97b87 100644
--- a/src/commands/rejoin.ts
+++ b/src/commands/rejoin.ts
@@ -9,7 +9,7 @@ import {
 import { checkCanJoin, checkUserAlreadyJoined } from '../components/preCheck.js'
 import { LeaveCause } from '../connectionCtx.js'
 import { newGuildTextBasedChannelId, newVoiceBasedChannelId } from '../id.js'
-import { getErrorReply } from '../utils.js'
+import { deferredReplyOrEdit, getErrorReply } from '../utils.js'
 
 export class RejoinCommand extends Command {
   public constructor(
@@ -75,6 +75,9 @@ export class RejoinCommand extends Command {
           existingJoinConfig.guildId,
           existingJoinConfig.channelId ?? '',
         )
+
+      await interaction.deferReply()
+
       const cause =
         interaction.channel.id ===
         guildCtx.connectionManager.channelMap.get(voiceChannelId)
@@ -104,7 +107,7 @@ export class RejoinCommand extends Command {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/commands/reset.ts b/src/commands/reset.ts
index 10f5b070..edd47bd0 100644
--- a/src/commands/reset.ts
+++ b/src/commands/reset.ts
@@ -1,7 +1,7 @@
 import { Command, type ChatInputCommand } from '@sapphire/framework'
 import { guildCtxManager } from '../index.js'
 import { PermissionFlagsBits, type InteractionReplyOptions } from 'discord.js'
-import { getErrorReply } from '../utils.js'
+import { deferredReplyOrEdit, getErrorReply } from '../utils.js'
 
 export class ResetCommand extends Command {
   public constructor(
@@ -30,6 +30,7 @@ export class ResetCommand extends Command {
     interaction: ChatInputCommand.Interaction,
   ) {
     if (!interaction.inCachedGuild()) return
+    await interaction.deferReply()
 
     let interactionReplyOptions: InteractionReplyOptions = {
       embeds: [
@@ -55,7 +56,7 @@ export class ResetCommand extends Command {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/commands/userSettings.ts b/src/commands/userSettings.ts
index 0ebdaba8..4ad9bb3c 100644
--- a/src/commands/userSettings.ts
+++ b/src/commands/userSettings.ts
@@ -1,5 +1,6 @@
 import { Subcommand } from '@sapphire/plugin-subcommands'
 import {
+  deferredReplyOrEdit,
   getErrorReply,
   userSettingToString,
   userSettingToDiff,
@@ -124,6 +125,8 @@ export class UserSettingsCommand extends Subcommand {
     interaction: Subcommand.ChatInputCommandInteraction,
   ) {
     if (!interaction.inCachedGuild()) return
+    await interaction.deferReply({ ephemeral: true })
+
     const user = interaction.options.getUser('user')
 
     let interactionReplyOptions: InteractionReplyOptions = {
@@ -160,7 +163,7 @@ export class UserSettingsCommand extends Subcommand {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 
@@ -168,6 +171,8 @@ export class UserSettingsCommand extends Subcommand {
     interaction: Subcommand.ChatInputCommandInteraction,
   ) {
     if (!interaction.inCachedGuild()) return
+    await interaction.deferReply({ ephemeral: true })
+
     const allowedVoiceList = [
       'show',
       'haruka',
@@ -280,7 +285,7 @@ export class UserSettingsCommand extends Subcommand {
       interactionReplyOptions = getErrorReply(error)
       console.error(error)
     } finally {
-      void interaction.reply(interactionReplyOptions)
+      void deferredReplyOrEdit(interaction, interactionReplyOptions)
     }
   }
 }
diff --git a/src/connectionCtx.ts b/src/connectionCtx.ts
index 4d82fdd0..54917883 100644
--- a/src/connectionCtx.ts
+++ b/src/connectionCtx.ts
@@ -174,6 +174,15 @@ export class ConnectionCtxManager extends Map<
     return this.get(this.channelMap.get(voiceChannelId))
   }
 
+  checkAlreadyUsedChannel(readChannelId: GuildTextBasedChannelId) {
+    const existingJoinConfig = this.get(readChannelId)?.connection.joinConfig
+    if (existingJoinConfig !== undefined)
+      throw new AlreadyUsedChannelError(
+        existingJoinConfig.guildId,
+        existingJoinConfig.channelId ?? '',
+      )
+  }
+
   connectionJoin({
     voiceChannelId,
     guildId,
@@ -190,12 +199,7 @@ export class ConnectionCtxManager extends Map<
     skipUser?: Set<UserId>
   }) {
     if (this.channelMap.has(voiceChannelId)) throw new AlreadyJoinedError()
-    const existingJoinConfig = this.get(readChannelId)?.connection.joinConfig
-    if (existingJoinConfig !== undefined)
-      throw new AlreadyUsedChannelError(
-        existingJoinConfig.guildId,
-        existingJoinConfig.channelId ?? '',
-      )
+    this.checkAlreadyUsedChannel(readChannelId)
     this.channelMap.set(voiceChannelId, readChannelId)
     const connection = joinVoiceChannel({
       channelId: voiceChannelId,
@@ -219,6 +223,7 @@ export class ConnectionCtxManager extends Map<
     this.set(readChannelId, connectionContext)
     return connectionContext
   }
+
   async connectionLeave({
     voiceChannelId,
     cause = undefined,
diff --git a/src/guildCtx.ts b/src/guildCtx.ts
index e344be9b..e59ea1d3 100644
--- a/src/guildCtx.ts
+++ b/src/guildCtx.ts
@@ -33,6 +33,11 @@ export class GuildContext {
     this.connectionManager = new ConnectionCtxManager()
   }
 
+  checkAlreadyJoined(voiceChannelId: VoiceBasedChannelId) {
+    if (this.connectionManager.channelMap.has(voiceChannelId))
+      throw new AlreadyJoinedError()
+  }
+
   async join({
     voiceChannelId,
     readChannelId,
@@ -42,8 +47,7 @@ export class GuildContext {
     readChannelId: GuildTextBasedChannelId
     skipUser?: Set<UserId>
   }) {
-    if (this.connectionManager.channelMap.has(voiceChannelId))
-      throw new AlreadyJoinedError()
+    this.checkAlreadyJoined(voiceChannelId)
 
     const vcArray = (await this.guild.channels.fetch())
       .map((v) => {
diff --git a/src/utils.ts b/src/utils.ts
index 32d1deb3..4b2b023d 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -2,7 +2,10 @@ import debug from 'debug'
 const debug__Queue = debug('utils.js:Queue')
 import { EventEmitter } from 'events'
 import { SpeakerList, type userSetting } from '@prisma/client'
-import type { InteractionReplyOptions } from 'discord.js'
+import type {
+  ChatInputCommandInteraction,
+  InteractionReplyOptions,
+} from 'discord.js'
 import { PowError } from './errors/PowError.js'
 
 export function getProperty<T, K extends keyof T>(property: K) {
@@ -118,3 +121,12 @@ export const getErrorReply = (error: unknown): InteractionReplyOptions => {
     throw error
   }
 }
+
+export const deferredReplyOrEdit = (
+  interaction: ChatInputCommandInteraction,
+  replyOptions: InteractionReplyOptions,
+) => {
+  return interaction.deferred
+    ? interaction.editReply(replyOptions)
+    : interaction.reply(replyOptions)
+}