// npm install discord.js@14 // Demo state is in memory. Replace attempts/locks with durable application state. import { randomUUID } from 'node:crypto'; import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder } from 'discord.js'; import { createVerification, getVerification } from './sendwich-client.mjs'; const { DISCORD_TOKEN, DISCORD_GUILD_ID, DISCORD_ROLE_ID, SENDWICH_API_KEY } = process.env; if (!DISCORD_TOKEN || !DISCORD_GUILD_ID || !DISCORD_ROLE_ID || !SENDWICH_API_KEY?.startsWith('veri_live_')) { throw new Error('Set the four server environment variables from the documentation.'); } const client = new Client({ intents: [GatewayIntentBits.Guilds] }); const attempts = new Map(); const locks = new Set(); client.once(Events.ClientReady, async ready => { try { // Creates/updates this command only; does not replace unrelated commands. await ready.application.commands.create( new SlashCommandBuilder().setName('인증').setDescription('휴대폰 번호 인증을 시작해요.').toJSON(), DISCORD_GUILD_ID ); console.log('sendwich verification command ready.'); } catch { console.error('Could not register the command. Check Discord permissions.'); } }); function instructions(attempt) { const session = attempt.session; return { content: `본인 휴대폰에서 아래 문자를 보내 주세요.\n받는 번호: ${session.destination}\n인증 문자: ${session.sms_text}\n만료: \n코드를 다른 사람에게 공유하지 마세요. 문자 요금이 발생할 수 있어요.`, components: [new ActionRowBuilder().addComponents( new ButtonBuilder().setCustomId('sw:check:' + attempt.reference) .setLabel('인증 확인').setStyle(ButtonStyle.Primary) )], allowedMentions: { parse: [] } }; } async function handle(interaction) { const start = interaction.isChatInputCommand() && interaction.commandName === '인증'; const check = interaction.isButton() && interaction.customId.startsWith('sw:check:'); if ((!start && !check) || interaction.guildId !== DISCORD_GUILD_ID) return; // Acknowledge promptly; the SMS may arrive much later. if (start) await interaction.deferReply({ flags: MessageFlags.Ephemeral }); else await interaction.deferUpdate(); const owner = interaction.guildId + ':' + interaction.user.id; if (locks.has(owner)) { await interaction.followUp({ content: '확인 중이에요. 잠시 기다려 주세요.', flags: MessageFlags.Ephemeral }); return; } locks.add(owner); try { let attempt = attempts.get(owner); if (start) { if (!attempt || Date.now() > attempt.keepUntil) { attempt = { reference: randomUUID(), state: randomUUID(), keepUntil: Date.now() + 20 * 60000, lastCheck: 0 }; attempts.set(owner, attempt); } if (!attempt.session) { // A retry after a timeout retains this request and its idempotency key. attempt.session = await createVerification({ mode: 'discover', client_reference: attempt.reference, state: attempt.state }, attempt.reference); } if (Date.parse(attempt.session.expires_at) <= Date.now()) { attempts.delete(owner); await interaction.editReply({ content: '이전 인증이 만료됐어요. /인증으로 다시 시작해 주세요.', components: [] }); } else await interaction.editReply(instructions(attempt)); return; } if (!attempt?.session || interaction.customId !== 'sw:check:' + attempt.reference) { await interaction.editReply({ content: '이 시도를 찾을 수 없어요. /인증으로 다시 시작해 주세요.', components: [] }); return; } if (Date.now() - attempt.lastCheck < 5000) return; attempt.lastCheck = Date.now(); const result = await getVerification(attempt.session.id); if (result.id !== attempt.session.id || result.environment !== 'live' || result.mode !== 'discover' || result.client_reference !== attempt.reference || result.state !== attempt.state) throw new Error('Unexpected result.'); if (result.status === 'pending') { await interaction.editReply({ ...instructions(attempt), content: instructions(attempt).content + '\n아직 확인되지 않았어요. 전송 후 잠시 뒤 확인해 주세요.' }); return; } if (result.status === 'verified' && result.phone) { const member = await interaction.guild.members.fetch(interaction.user.id); // Adding this one role is idempotent. Do not post the full phone in chat. await member.roles.add(DISCORD_ROLE_ID, 'sendwich phone verification'); await interaction.editReply({ content: '인증됐어요. 역할을 부여했어요.', components: [] }); } else if (['expired', 'cancelled'].includes(result.status)) { await interaction.editReply({ content: '인증이 만료되거나 취소됐어요. /인증으로 다시 시작해 주세요.', components: [] }); } else throw new Error('Unexpected result.'); attempts.delete(owner); } catch (error) { const message = error.code === 'monthly_call_limit' ? '서비스의 월 호출 한도에 도달했어요. 운영자에게 문의해 주세요.' : '처리하지 못했어요. 잠시 뒤 다시 확인해 주세요. 시작 중이었다면 /인증으로 재시도해 주세요.'; await interaction.followUp({ content: message, flags: MessageFlags.Ephemeral }); } finally { locks.delete(owner); } } client.on(Events.InteractionCreate, interaction => { void handle(interaction).catch(() => console.error('Discord response unavailable.')); }); setInterval(() => { for (const [owner, attempt] of attempts) if (attempt.keepUntil < Date.now() && !locks.has(owner)) attempts.delete(owner); }, 60000).unref(); await client.login(DISCORD_TOKEN);