feat: Introduce guild-specific configuration for temporary voice channels with a new command, updated service logic, and corresponding database schema changes.

This commit is contained in:
이정수 2026-03-27 15:41:27 +09:00
parent 234a0e96fe
commit 9f891112d9
10 changed files with 254 additions and 10 deletions

View File

@ -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")
);

View File

@ -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");

View File

@ -65,11 +65,14 @@ model TempVoiceChannel {
} }
model UserVoiceProfile { model UserVoiceProfile {
userId String @id userId String
guildId String
customName String? customName String?
userLimit Int? userLimit Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@id([userId, guildId])
} }
model UserLocale { model UserLocale {
@ -98,3 +101,11 @@ model AuditChannel {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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
}

120
src/commands/voiceConfig.ts Normal file
View File

@ -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
});
}
},
};

View File

@ -139,9 +139,9 @@ export default {
await voiceChannel.setName(newName); await voiceChannel.setName(newName);
await prisma.userVoiceProfile.upsert({ await prisma.userVoiceProfile.upsert({
where: { userId: ownerId }, where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
update: { customName: newName }, 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 }); await interaction.reply({ content: t(locale, 'voice.responses.channelRenamed', { name: newName }), ephemeral: true });
@ -155,9 +155,9 @@ export default {
await voiceChannel.setUserLimit(limit); await voiceChannel.setUserLimit(limit);
await prisma.userVoiceProfile.upsert({ await prisma.userVoiceProfile.upsert({
where: { userId: ownerId }, where: { userId_guildId: { userId: ownerId, guildId: interaction.guildId! } },
update: { userLimit: limit }, 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); const limitDisplay = limit === 0 ? t(locale, 'voice.responses.limitUnlimited') : String(limit);

View File

@ -123,6 +123,18 @@ export const en: TranslationSchema = {
setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!', setSuccess: 'Successfully set up {{channel}} as a Voice Generator Channel!',
createSuccess: 'Successfully created and 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: { language: {
description: 'Set the language for the bot.', description: 'Set the language for the bot.',
scopeDescription: 'Apply to yourself or the entire server', scopeDescription: 'Apply to yourself or the entire server',

View File

@ -123,6 +123,18 @@ export const ko: TranslationSchema = {
setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!', setSuccess: '{{channel}}을(를) 음성 생성기 채널로 설정했습니다!',
createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!', createSuccess: '{{channel}}을(를) 음성 생성기 채널로 생성 및 설정했습니다!',
}, },
voiceConfig: {
description: '서버의 임시 음성 채널 설정을 관리합니다.',
setNameTitle: '기본 이름 템플릿 설정',
setNameDesc: '임시 채널 생성 시 사용할 기본 이름 형식을 설정합니다. (사용자명: {{username}})',
setLimitTitle: '기본 인원 제한 설정',
setLimitDesc: '임시 채널 생성 시 적용할 기본 인원 제한을 설정합니다.',
statusTitle: '현재 서버 음성 설정',
templateLabel: '이름 템플릿',
limitLabel: '기본 인원 제한',
setSuccess: '서버의 임시 채널 설정이 업데이트되었습니다.',
limitValue: '{{limit}}명 (0 = 무제한)',
},
language: { language: {
description: '봇의 언어를 설정합니다.', description: '봇의 언어를 설정합니다.',
scopeDescription: '본인에게만 또는 서버 전체에 적용', scopeDescription: '본인에게만 또는 서버 전체에 적용',

View File

@ -77,6 +77,18 @@ export interface TranslationSchema {
setSuccess: string; setSuccess: string;
createSuccess: 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: { language: {
description: string; description: string;
scopeDescription: string; scopeDescription: string;

View File

@ -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 { prisma } from '../database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { redis } from '../cache'; import { redis } from '../cache';
@ -134,9 +134,29 @@ export class VoiceService {
// Resolve locale for this context // Resolve locale for this context
const locale = await getContextLocale(guild.id, member.id); const locale = await getContextLocale(guild.id, member.id);
const profile = await prisma.userVoiceProfile.findUnique({ where: { userId: member.id }}); // Fetch guild-specific config
const channelName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: member.user.username }); const guildConfig = await prisma.voiceGuildConfig.findUnique({ where: { guildId: guild.id } });
const userLimit = profile?.userLimit || 0; 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 { try {
const parentId = generator.categoryId || state.channel?.parentId || undefined; 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) { public static async applyOwnershipTransfer(channel: VoiceChannel, oldOwnerId: string, newOwnerId: string) {
const locale = await getContextLocale(channel.guildId, newOwnerId); 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 newMember = await channel.guild.members.fetch(newOwnerId);
const finalName = profile?.customName || t(locale, 'voice.defaultRoomName', { username: newMember.user.username }); 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); 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;
}
} }

View File

@ -8,4 +8,27 @@ describe('VoiceService Test Suite', () => {
await VoiceService.handleVoiceStateUpdate(mockState, mockState); await VoiceService.handleVoiceStateUpdate(mockState, mockState);
expect(true).toBe(true); 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');
});
}); });