본문 바로가기

서비스 창작 기록

코인 손절 알람기 만들기 - 3. 디스코드 알림 기능 - discord bot, nextjs, react, typescript

진행 과정:

1. 틀 만들기
2. 손절가 계산
3. 알림 기능 --- 현재

알림 기능 개발 과정:

1. 알림 기능 정의

- 손절가에 왔을 때 이를 알아차리고

- 알림 요청 받은 사용자에게 알림 전송

 

2. 알림 요청 받는 방법

- 로그인된 사용자

-- 알림 요청 받을 디스코드 ID를 적었으면, 그 디스코드 ID를 이용해서 알림 보냄

--- 아이디 수정 가능하도록 함

-- 알림 요청 받을 디스코드 ID가 없으면, 디스코드 ID 입력창 띄움

- 로그인 안된 사용자

-- 로그인 하도록 요청

 

3. 손절가 왔을 때 알아차릴 방법

- 거래소 선택

- 확인 주기 선택

-- 무료: 분단위

-- 유료: 초단위

- 확인 방법: 매 분 또는 매 초 REST API 요청 후 가격 확인

- scale-up ready: dedicated runtime 을 만들어서. 얘네들만 새로 띄움. DB 공유.

 

4. 팝업 UI 기획

- 기획

- 종목: (자신이 선택한 종목이 맞는지 확인용)

- 주기: 선택

- 방법: 디스코드 (1차)

- 방법 선택 시 팝업: 디스코드 아이디 입력창

 

5. 신청 버튼 동작 기획

- POST 요청:

=> 종목, 주기, 방법, 디스코드 ID

- Endpoint:

=> 봇이 보는 DB 업데이트

- 값 확인:

=> 입력받은 디스코드 아이디 없는지 확인

 

6. dedicated runtime 기획

- 1차로는 nodejs 에서 discord bot과 통신하는 통로 개설

=> 신청 버튼 Endpoint 동작: 1차로는 discord bot에게 입력 받은 내용 전달

- 1차로는 discord bot에서 discord user로 테스트 메시지 보내기

=> 종목, 주기, 방법으로 알림이 신청되었다는 메시지를 입력받은 디스코드 ID로 보내기

 

7. 데이터 객체 기획

- DB: 1차로는 in-memory

- entity:

-- user: 1차로는 로그인 되었다고 가정

-- subscription: stop-loss price, discord id, interval, ticker, exchange (1차로는 upbit 라고 가정)

 

8. 구현 진행

- 팝업 창 틀 만들기:

=> cursor composer 사용 modal 만듬

=> three selector input 만듬

=> input 앞에 label 만듬

=> modal 맨 밑에 submit button 만듬

 

- 알림 신청 버튼 클릭 시 창 띄우기:

=> cursor composer 사용

=> feedback: 아직 hover 해서 page 오른쪽 메뉴 아래에 뜨지 않음

 

- 알람 방법에서 디스코드 선택시 창 띄우기:

=> cursor composer 사용

 

- 디스코드 봇 생성 및 권한 부여:

=> ChatGPT 사용해서 따름: 예시 prompt

 

- 디스코드 메시지 보내는 로직 구현:

 

=> discord.js pkg 설치

==> terminal:

npm install discord.js
npm install --save-dev @types/node

=> bot token 설정

==> env.local 수정:

DISCORD_BOT_TOKEN=your-bot-token-here

=> client 생성

==> /app/api/discord/send/route.ts 생성

import { NextResponse } from 'next/server';
import { Client, GatewayIntentBits, GuildMember } from 'discord.js';

// Initialize the Discord client
const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.DirectMessages,
  ],
});

// Log in the Discord client
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN || '';
if (!BOT_TOKEN) {
  throw new Error('Discord bot token is missing from environment variables.');
}

client.login(BOT_TOKEN).catch((error) => {
  console.error('Failed to log in Discord client:', error);
});

=> server nickname 으로 discord id 얻기

==> /app/api/discord/send/route.ts 수정

async function getUserIdByNickname(nickname: string): Promise<string | null> {
  try {
    const guilds = client.guilds.cache;

    for (const guild of guilds.values()) {
      const members = await guild.members.fetch(); // Fetch all members in the guild
      const member = members.find((m: GuildMember) => m.nickname === nickname);

      if (member) {
        return member.user.id;
      }
    }

    console.warn(`No user found with nickname "${nickname}" across all guilds.`);
    return null;
  } catch (error) {
    console.error(`Error searching for user by nickname "${nickname}":`, error);
    return null;
  }
}

=> discord id로 메시지 전송

==> /app/api/discord/send/route.ts 수정

 

=> api route handler

==> /app/api/discord/send/route.ts 수정

// API route handler
export async function POST(request: Request) {
  try {
    const { nickname, message } = await request.json();

    // Input validation
    if (!nickname || typeof nickname !== 'string') {
      return NextResponse.json(
        { error: 'Invalid or missing "nickname".' },
        { status: 400 }
      );
    }
    if (!message || typeof message !== 'string') {
      return NextResponse.json(
        { error: 'Invalid or missing "message".' },
        { status: 400 }
      );
    }

    // Step 1: Get the user ID by nickname across all guilds
    const userId = await getUserIdByNickname(nickname);
    if (!userId) {
      return NextResponse.json(
        { error: `User with nickname "${nickname}" not found in any guild.` },
        { status: 404 }
      );
    }

    // Step 2: Send a DM to the user
    await sendDM(userId, message);

    return NextResponse.json(
      { success: true, message: `DM successfully sent to user with nickname "${nickname}".` },
      { status: 200 }
    );
  } catch (error) {
    console.error('Error in API route handler:', error);
    return NextResponse.json(
      { error: 'Failed to send DM. Please check the server logs for details.' },
      { status: 500 }
    );
  }

- 테스트 진행:

=> app/discord/send/page.tsx 생성

'use client';

import { useState } from 'react';

export default function SendDMPage() {
  const [nickname, setNickname] = useState('');
  const [message, setMessage] = useState('');
  const [status, setStatus] = useState('');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('Sending...');

    try {
      const response = await fetch('/api/discord/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nickname, message }),
      });

      const result = await response.json();

      if (response.ok) {
        setStatus('Message sent successfully!');
      } else {
        setStatus(`Error: ${result.error}`);
      }
    } catch (error) {
      setStatus('An unexpected error occurred.');
      console.error(error);
    }
  };

  return (
    <div>
      <h1>Send DM to Discord User</h1>
      <form onSubmit={handleSubmit}>
        <input
          type="text"
          placeholder="Nickname"
          value={nickname}
          onChange={(e) => setNickname(e.target.value)}
          required
        />
        <textarea
          placeholder="Message"
          value={message}
          onChange={(e) => setMessage(e.target.value)}
          required
        ></textarea>
        <button type="submit">Send DM</button>
      </form>
      <p>{status}</p>
    </div>
  );
}

- 디버깅 진행:

=> zlib-sync compile error

==> discord.js 대신 rest api 요청으로 변경

=> nick 값이 없음. nickname 대신 username 사용

import { NextResponse } from 'next/server';

// Environment variable for the bot token
const BOT_TOKEN = process.env.DISCORD_BOT_TOKEN || '';
if (!BOT_TOKEN) {
  throw new Error('Discord bot token is missing from environment variables.');
}

const BASE_URL = 'https://discord.com/api/v10';

// Helper function to make authenticated API requests
async function discordApiRequest(endpoint: string, options: RequestInit = {}) {
  const response = await fetch(`${BASE_URL}${endpoint}`, {
    ...options,
    headers: {
      Authorization: `Bot ${BOT_TOKEN}`,
      'Content-Type': 'application/json',
      ...options.headers,
    },
  });

  if (!response.ok) {
    const errorData = await response.json();
    throw new Error(
      `Discord API Error: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`
    );
  }
  const data = await response.json();
  console.log(data);
  return data;
}

// Function to get user ID by username
async function getUserIdByUsername(username: string): Promise<string | null> {
  try {
    const guilds = await discordApiRequest('/users/@me/guilds');
    for (const guild of guilds) {
      const members = await discordApiRequest(`/guilds/${guild.id}/members?limit=1000`);
      const member = members.find((m: any) => (m.user.username) === username);

      if (member) {
        return member.user.id;
      }
    }

    return null;
  } catch (error) {
    console.error(`Error finding user by username "${username}":`, error);
    return null;
  }
}

// Function to send a DM to a user by their ID
async function sendDM(userId: string, message: string): Promise<void> {
  try {
    const dmChannel = await discordApiRequest('/users/@me/channels', {
      method: 'POST',
      body: JSON.stringify({ recipient_id: userId }),
    });

    await discordApiRequest(`/channels/${dmChannel.id}/messages`, {
      method: 'POST',
      body: JSON.stringify({ content: message }),
    });

    console.log(`Message successfully sent to user ID ${userId}`);
  } catch (error) {
    console.error(`Error sending DM to user "${userId}":`, error);
    throw error;
  }
}

// API route handler
export async function POST(req: Request) {
  try {
    const { username, message } = await req.json();

    // Input validation
    if (!username || typeof username !== 'string') {
      return NextResponse.json({ error: 'Invalid or missing "username".' }, { status: 400 });
    }
    if (!message || typeof message !== 'string') {
      return NextResponse.json({ error: 'Invalid or missing "message".' }, { status: 400 });
    }

    // Step 1: Get the user ID by username across all guilds
    const userId = await getUserIdByUsername(username);
    if (!userId) {
      return NextResponse.json(
        { error: `User with username "${username}" not found in any guild.` },
        { status: 404 }
      );
    }

    // Step 2: Send a DM to the user
    await sendDM(userId, message);

    return NextResponse.json(
      { success: true, message: `DM successfully sent to user with username "${username}".` },
      { status: 200 }
    );
  } catch (error) {
    console.error('Error in API route handler:', error);
    return NextResponse.json(
      { error: 'Failed to send DM. Please check the server logs for details.' },
      { status: 500 }
    );
  }
}

 

- 팝업 창 내 신청 버튼 로직 구현:

=> api/discord/send 로 username, ticker, interval, stop-loss price 전송

const handleSubmit = async () => {
    if (!data) return;

    const interval = (document.getElementById('interval-select') as HTMLSelectElement).value;
    const method = (document.getElementById('method-select') as HTMLSelectElement).value;

    const message = `ticker: ${ticker}, stop-loss price: ${data.swingLowMinusATR.toFixed(2)}, interval: ${interval}, method: ${method}`;

    setStatus('Sending...');

    try {
      const response = await fetch('/api/discord/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: discordId, message }),
      });

      const result = await response.json();

      if (response.ok) {
        setStatus('Message sent successfully!');
      } else {
        setStatus(`Error: ${result.error}`);
      }
    } catch (error) {
      setStatus('An unexpected error occurred.');
      console.error(error);
    }
  };

 

- 테스트 진행 및 결과:

관련 코드 (github):

- https://github.com/tyson-park/crypto-stop-loss-alarm/commit/eb6d5541345c9343ceeb28694b1793d7910a5f2f

 

이전 글:

- 코인 손절 알람기 만들기 - 2. 손절가 계산 - nextjs, ChatGPT, 업비트 REST API