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