From 9f891112d9d3ff7fa039d1de4f63b5b0f73f5b54 Mon Sep 17 00:00:00 2001 From: artbiit Date: Fri, 27 Mar 2026 15:41:27 +0900 Subject: [PATCH] feat: Introduce guild-specific configuration for temporary voice channels with a new command, updated service logic, and corresponding database schema changes. --- .../migration.sql | 10 ++ .../migration.sql | 11 ++ prisma/schema.prisma | 13 +- src/commands/voiceConfig.ts | 120 ++++++++++++++++++ src/events/interactionCreate.ts | 8 +- src/i18n/locales/en.ts | 12 ++ src/i18n/locales/ko.ts | 12 ++ src/i18n/types.ts | 12 ++ src/services/VoiceService.ts | 43 ++++++- tests/services/VoiceService.test.ts | 23 ++++ 10 files changed, 254 insertions(+), 10 deletions(-) create mode 100644 prisma/migrations/20260327063258_add_voice_guild_config/migration.sql create mode 100644 prisma/migrations/20260327063846_refactor_user_voice_profile_per_guild/migration.sql create mode 100644 src/commands/voiceConfig.ts diff --git a/prisma/migrations/20260327063258_add_voice_guild_config/migration.sql b/prisma/migrations/20260327063258_add_voice_guild_config/migration.sql new file mode 100644 index 0000000..69e033e --- /dev/null +++ b/prisma/migrations/20260327063258_add_voice_guild_config/migration.sql @@ -0,0 +1,10 @@ +-- CreateTable +CREATE TABLE "VoiceGuildConfig" ( + "guildId" TEXT NOT NULL, + "defaultNameTemplate" TEXT NOT NULL DEFAULT '{{username}}''s Room', + "defaultUserLimit" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VoiceGuildConfig_pkey" PRIMARY KEY ("guildId") +); diff --git a/prisma/migrations/20260327063846_refactor_user_voice_profile_per_guild/migration.sql b/prisma/migrations/20260327063846_refactor_user_voice_profile_per_guild/migration.sql new file mode 100644 index 0000000..151fc9e --- /dev/null +++ b/prisma/migrations/20260327063846_refactor_user_voice_profile_per_guild/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - The primary key for the `UserVoiceProfile` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `guildId` to the `UserVoiceProfile` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "UserVoiceProfile" DROP CONSTRAINT "UserVoiceProfile_pkey", +ADD COLUMN "guildId" TEXT NOT NULL, +ADD CONSTRAINT "UserVoiceProfile_pkey" PRIMARY KEY ("userId", "guildId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b443e46..21bde11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -65,11 +65,14 @@ model TempVoiceChannel { } model UserVoiceProfile { - userId String @id + userId String + guildId String customName String? userLimit Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@id([userId, guildId]) } model UserLocale { @@ -98,3 +101,11 @@ model AuditChannel { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model VoiceGuildConfig { + guildId String @id + defaultNameTemplate String @default("{{username}}'s Room") + defaultUserLimit Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/commands/voiceConfig.ts b/src/commands/voiceConfig.ts new file mode 100644 index 0000000..f9016a9 --- /dev/null +++ b/src/commands/voiceConfig.ts @@ -0,0 +1,120 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChatInputCommandInteraction, EmbedBuilder } from 'discord.js'; +import { prisma } from '../database'; +import { SupportedLocale } from '../i18n'; +import { t } from '../i18n'; +import { logger } from '../utils/logger'; + +export default { + data: new SlashCommandBuilder() + .setName('voice-config') + .setDescription('Manage guild-specific settings for temporary voice channels.') + .setDescriptionLocalizations({ + ko: '서버의 임시 음성 채널 설정을 관리합니다.', + }) + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('set-name') + .setDescription('Set the default naming template for new temp channels.') + .setDescriptionLocalizations({ + ko: '임시 채널의 기본 이름 템플릿을 설정합니다.', + }) + .addStringOption(option => + option.setName('template') + .setDescription('Template using {{username}} placeholder') + .setDescriptionLocalizations({ + ko: '{{username}}을 포함한 이름 템플릿', + }) + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('set-limit') + .setDescription('Set the default user limit for new temp channels.') + .setDescriptionLocalizations({ + ko: '임시 채널의 기본 인원 제한을 설정합니다.', + }) + .addIntegerOption(option => + option.setName('limit') + .setDescription('User limit (0-99, 0 = unlimited)') + .setDescriptionLocalizations({ + ko: '인원 제한 (0-99, 0 = 무제한)', + }) + .setRequired(true) + .setMinValue(0) + .setMaxValue(99) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('View current guild voice settings.') + .setDescriptionLocalizations({ + ko: '현재 서버의 음성 설정을 확인합니다.', + }) + ), + + async execute(interaction: ChatInputCommandInteraction, locale: SupportedLocale) { + const subcommand = interaction.options.getSubcommand(); + const guildId = interaction.guildId!; + + try { + if (subcommand === 'set-name') { + const template = interaction.options.getString('template', true); + await prisma.voiceGuildConfig.upsert({ + where: { guildId }, + update: { defaultNameTemplate: template }, + create: { guildId, defaultNameTemplate: template } + }); + + return interaction.reply({ + content: t(locale, 'commands.voiceConfig.setSuccess'), + ephemeral: true + }); + } + + if (subcommand === 'set-limit') { + const limit = interaction.options.getInteger('limit', true); + await prisma.voiceGuildConfig.upsert({ + where: { guildId }, + update: { defaultUserLimit: limit }, + create: { guildId, defaultUserLimit: limit } + }); + + return interaction.reply({ + content: t(locale, 'commands.voiceConfig.setSuccess'), + ephemeral: true + }); + } + + if (subcommand === 'status') { + const config = await prisma.voiceGuildConfig.findUnique({ where: { guildId } }); + + const embed = new EmbedBuilder() + .setTitle(t(locale, 'commands.voiceConfig.statusTitle')) + .setColor(0x5865F2) + .addFields( + { + name: t(locale, 'commands.voiceConfig.templateLabel'), + value: `\`${config?.defaultNameTemplate || t(locale, 'voice.defaultRoomName')}\``, + inline: true + }, + { + name: t(locale, 'commands.voiceConfig.limitLabel'), + value: t(locale, 'commands.voiceConfig.limitValue', { limit: config?.defaultUserLimit ?? 0 }), + inline: true + } + ); + + return interaction.reply({ embeds: [embed], ephemeral: true }); + } + } catch (error) { + logger.error('Error in voice-config command', error); + return interaction.reply({ + content: t(locale, 'errors.E3003.userMessage'), + ephemeral: true + }); + } + }, +}; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 35768fb..f7d2df1 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -139,9 +139,9 @@ export default { await voiceChannel.setName(newName); await prisma.userVoiceProfile.upsert({ - where: { userId: ownerId }, + where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } }, update: { customName: newName }, - create: { userId: ownerId, customName: newName } + create: { userId: ownerId, guildId: interaction.guildId!, customName: newName } }); await interaction.reply({ content: t(locale, 'voice.responses.channelRenamed', { name: newName }), ephemeral: true }); @@ -155,9 +155,9 @@ export default { await voiceChannel.setUserLimit(limit); await prisma.userVoiceProfile.upsert({ - where: { userId: ownerId }, + where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } }, update: { userLimit: limit }, - create: { userId: ownerId, userLimit: limit } + create: { userId: ownerId, guildId: interaction.guildId!, userLimit: limit } }); const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f4237af..389f684 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -123,6 +123,18 @@ export const en: TranslationSchema = { setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!', createSuccess: 'Successfully created and set up {{channel}} as a Voice Generator Channel!', }, + voiceConfig: { + description: 'Manage guild-specific settings for temporary voice channels.', + setNameTitle: 'Set Default Name Template', + setNameDesc: 'Set the default naming format for new temp channels. (Username placeholder: {{username}})', + setLimitTitle: 'Set Default User Limit', + setLimitDesc: 'Set the default user limit for new temp channels.', + statusTitle: 'Current Server Voice Settings', + templateLabel: 'Name Template', + limitLabel: 'Default User Limit', + setSuccess: 'Server temporary channel settings updated successfully.', + limitValue: '{{limit}} users (0 = unlimited)', + }, language: { description: 'Set the language for the bot.', scopeDescription: 'Apply to yourself or the entire server', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index d0baca0..9536ef6 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -123,6 +123,18 @@ export const ko: TranslationSchema = { setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!', createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!', }, + voiceConfig: { + description: '서버의 임시 음성 채널 설정을 관리합니다.', + setNameTitle: '기본 이름 템플릿 설정', + setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})', + setLimitTitle: '기본 인원 제한 설정', + setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.', + statusTitle: '현재 서버 음성 설정', + templateLabel: '이름 템플릿', + limitLabel: '기본 인원 제한', + setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.', + limitValue: '{{limit}}명 (0 = 무제한)', + }, language: { description: '봇의 언어를 설정합니다.', scopeDescription: '본인에게만 또는 서버 전체에 적용', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 87c6867..d4fec4a 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -77,6 +77,18 @@ export interface TranslationSchema { setSuccess: string; createSuccess: string; }; + voiceConfig: { + description: string; + setNameTitle: string; + setNameDesc: string; + setLimitTitle: string; + setLimitDesc: string; + statusTitle: string; + templateLabel: string; + limitLabel: string; + setSuccess: string; + limitValue: string; + }; language: { description: string; scopeDescription: string; diff --git a/src/services/VoiceService.ts b/src/services/VoiceService.ts index 9e877e6..e23a7e7 100644 --- a/src/services/VoiceService.ts +++ b/src/services/VoiceService.ts @@ -1,4 +1,4 @@ -import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client } from 'discord.js'; +import { VoiceState, ChannelType, PermissionFlagsBits, VoiceChannel, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, Client, GuildMember } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; import { redis } from '../cache'; @@ -134,9 +134,29 @@ export class VoiceService { // Resolve locale for this context const locale = await getContextLocale(guild.id, member.id); - const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); - const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username }); - const userLimit = profile?.userLimit || 0; + // Fetch guild-specific config + const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } }); + const profile = await prisma.userVoiceProfile.findUnique({ + where: { userId_guildId: { userId: member.id, guildId: guild.id } } + }); + + // Fallback logic for name resolution + const effectiveName = this.getEffectiveName(member); + + // Naming priority: User Custom Profile > Guild Template > Default I18n + let channelName: string; + const profileName = profile?.customName; + if (profileName) { + channelName = profileName; + } else { + const template = guildConfig?.defaultNameTemplate || t(locale, 'voice.defaultRoomName'); + channelName = template.replace('{{username}}', effectiveName); + } + + // Final safety fallback to avoid undefined + if (!channelName) channelName = `${effectiveName}'s Room`; + + const userLimit = profile?.userLimit ?? guildConfig?.defaultUserLimit ?? 0; try { const parentId = generator.categoryId || state.channel?.parentId || undefined; @@ -271,7 +291,9 @@ export class VoiceService { public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) { const locale = await getContextLocale(channel.guildId, newOwnerId); - const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: newOwnerId }}); + const profile = await prisma.userVoiceProfile.findUnique({ + where: { userId_guildId: { userId: newOwnerId, guildId: channel.guildId } } + }); const newMember = await channel.guild.members.fetch(newOwnerId); const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username }); @@ -317,4 +339,15 @@ export class VoiceService { logger.error('Failed to send control panel UI', e); } } + + /** + * Resolves the member's name based on hierarchy: + * 1. Server Nickname + * 2. Global Display Name + * 3. Username + * 4. User ID (Fallback) + */ + public static getEffectiveName(member: GuildMember): string { + return member.nickname || member.user.globalName || member.user.username || member.id; + } } diff --git a/tests/services/VoiceService.test.ts b/tests/services/VoiceService.test.ts index 2bc12ec..b30263f 100644 --- a/tests/services/VoiceService.test.ts +++ b/tests/services/VoiceService.test.ts @@ -8,4 +8,27 @@ describe('VoiceService Test Suite', () => { await VoiceService.handleVoiceStateUpdate(mockState, mockState); expect(true).toBe(true); }); + + it('should resolve nickname correctly in getEffectiveName', () => { + const mockMember = { + id: '123', + nickname: 'ServerNick', + user: { + globalName: 'GlobalName', + username: 'UserBase', + id: '123' + } + } as any; + + expect(VoiceService.getEffectiveName(mockMember)).toBe('ServerNick'); + + mockMember.nickname = null; + expect(VoiceService.getEffectiveName(mockMember)).toBe('GlobalName'); + + mockMember.user.globalName = null; + expect(VoiceService.getEffectiveName(mockMember)).toBe('UserBase'); + + mockMember.user.username = null; + expect(VoiceService.getEffectiveName(mockMember)).toBe('123'); + }); });