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:
parent
234a0e96fe
commit
9f891112d9
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '본인에게만 또는 서버 전체에 적용',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue