# Discord 봇

> 비공개 인증 안내와 역할 부여를 구현하는 Node.js 봇

문서: https://sendwich.kr/docs/examples/discord/

## 동작 방식

`/인증` 명령을 실행하면 봇이 받는 번호와 인증 문자를 비공개 메시지로 표시합니다. 사용자가 휴대폰에서 문자를 보낸 뒤 **인증 확인**을 누르면 결과를 조회하고 역할을 부여합니다.

이 예제는 Discord Gateway를 사용하므로 별도의 웹훅 수신 서버 없이 실행됩니다. 결과 확인 버튼을 누를 때마다 조회 1회가 API 사용량에 반영됩니다.

## 준비

- Node.js 22.12 이상
- Discord 애플리케이션과 봇
- sendwich 프로젝트 API 키

Discord 서버에 봇을 초대할 때 `applications.commands`, `bot` 범위와 역할 관리 권한을 부여합니다. 봇의 역할은 부여할 역할보다 상위에 배치해야 합니다.

## 설치와 실행

새 디렉터리에서 discord.js를 설치하고 예제 파일을 내려받습니다.

```bash
npm install discord.js@14
curl -O https://sendwich.kr/docs/downloads/sendwich-client.mjs
curl -O https://sendwich.kr/docs/downloads/discord-bot.mjs
```

`.env`에 서버 환경변수를 설정합니다.

```dotenv
SENDWICH_API_KEY=YOUR_LIVE_SECRET_KEY
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_SERVER_ID
DISCORD_ROLE_ID=YOUR_VERIFIED_ROLE_ID
```

```bash
node --env-file=.env discord-bot.mjs
```

지정한 Discord 서버에 `/인증` 명령이 등록됩니다. 명령을 실행하고 안내된 번호로 인증 문자를 전송합니다.

## 예제 코드

[봇 코드 다운로드](https://sendwich.kr/docs/downloads/discord-bot.mjs) · [sendwich 클라이언트 다운로드](https://sendwich.kr/docs/downloads/sendwich-client.mjs)

```javascript
// 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만료: <t:${Math.floor(Date.parse(session.expires_at) / 1000)}:R>\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);
```

## 운영 환경 적용

예제는 인증 시도를 메모리에 저장하므로 봇을 재시작하면 진행 중인 시도가 사라집니다. 여러 프로세스에서 운영하거나 재시작 후 이어서 처리하려면 사용자·서버·인증 ID·state·처리 상태를 데이터베이스에 저장합니다.

사용자별 요청 제한을 적용하고, 인증 ID당 역할 부여를 한 번만 처리합니다. 한 전화번호의 다중 계정 사용 여부는 서비스 정책에 따라 정합니다. 특정 번호를 확인하려면 [match 모드](https://sendwich.kr/docs/verification-modes/)를 사용합니다.

## 역할 자동 부여

확인 버튼 없이 역할을 부여하려면 봇 서버에 [sendwich 웹훅](https://sendwich.kr/docs/webhooks/)을 수신하는 HTTPS 경로를 추가합니다. 수신 서버에서 서명과 인증 시도를 검증한 뒤 Discord API로 역할을 부여합니다.

Discord 채널의 웹훅은 sendwich 이벤트를 처리할 수 없으므로 중간 수신 서버가 필요합니다.

## 참고 문서

- [Discord 상호작용](https://docs.discord.com/developers/interactions/receiving-and-responding)
- [discord.js 역할 관리](https://discord.js.org/docs/packages/discord.js/14.26.2/GuildMemberRoleManager:Class)
