📑 이 챕터에서 다룰 내용
서버 보안 셋업이 끝났습니다. 이제 봇 코드를 직접 작성합니다. discord.js + @anthropic-ai/sdk를 결합하고, 시리즈 메타 원칙인 ⚖️ Position C와 LogOnTable을 일관되게 적용합니다.
| 항목 | 내용 |
|---|---|
| 예상 시간 | 1~2시간 (boilerplate + 첫 작동) |
| 완성 결과 | 디스코드 채널에서 !ask 명령 → Claude API 호출 → 답변 |
Node.js는 NVM(Node Version Manager)으로 설치하는 것을 권장합니다. 버전 전환이 쉽고 권한 문제가 없어요.
# NVM 설치 curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash # 셸 재시작 (또는 source) source ~/.bashrc # Node.js LTS 설치 (2026년 5월 기준 LTS = 22.x) nvm install 22 nvm use 22 nvm alias default 22 # 확인 node --version # v22.x.x npm --version # 10.x
Discord Developer Portal에서 봇 토큰을 발급받습니다. 토큰은 1회만 노출되니 반드시 안전하게 보관하세요.
[Discord Developer Portal] 1. https://discord.com/developers/applications 접속 2. "New Application" 클릭 3. 이름 입력 (예: "TSV Assistant") 4. 좌측 "Bot" 메뉴 5. "Reset Token" → 토큰 복사 ★ (★ 1회만 노출, 안전 보관) 6. ★ "Privileged Gateway Intents" 모두 ON: - PRESENCE INTENT - SERVER MEMBERS INTENT - MESSAGE CONTENT INTENT (★ 필수) [봇 초대 link 생성] 7. 좌측 "OAuth2" → "URL Generator" 8. SCOPES: "bot" 체크 9. BOT PERMISSIONS: - Read Messages/View Channels - Send Messages - Read Message History 10. 하단 URL 복사 → 브라우저 → 본인 서버 초대
MESSAGE CONTENT INTENT를 활성화하지 않으면 봇이 message.content를 읽을 수 없습니다.
반드시 3개 Privileged Gateway Intents 모두 ON으로 설정하세요.
작업 디렉토리를 만들고 필요한 패키지를 설치합니다.
# 작업 디렉토리 mkdir ~/tsv-bot cd ~/tsv-bot # package.json 초기화 npm init -y # 패키지 설치 npm install discord.js @anthropic-ai/sdk dotenv # 추가 (운영용) npm install --save-dev typescript @types/node ts-node nodemon npx tsc --init
디렉토리 구조
~/tsv-bot/ ├── .env # API 키 (★ git ignore) ├── .gitignore ├── package.json ├── tsconfig.json ├── src/ │ ├── bot.ts # 메인 봇 코드 │ ├── claude.ts # Anthropic SDK wrapper │ ├── logger.ts # LogOnTable 로깅 │ └── messages.ts # ⚖️ Position C 본문 메시지 └── logs/ # 로그 디렉토리 (★ git ignore)
API 키는 절대 코드에 직접 쓰지 마세요. .env 파일에 보관하고 .gitignore로 git에서 제외합니다.
# .env cat > .env << 'EOF' DISCORD_TOKEN=your_discord_bot_token_here ANTHROPIC_API_KEY=your_anthropic_api_key_here ANTHROPIC_MODEL=claude-sonnet-4-6 LOG_DIR=./logs EOF # .gitignore cat > .gitignore << 'EOF' node_modules/ .env logs/ dist/ *.log .DS_Store EOF # 권한 (보안) chmod 600 .env
.env를 GitHub에 push하면 Discord가 자동으로 토큰을 무효화합니다.
.gitignore에 반드시 포함하고, push 전에 확인하세요.
모든 봇 응답 끝에는 ⚖️ Position C disclaimer가 붙습니다. 시리즈 메타 원칙을 일관되게 적용하는 핵심 파일입니다.
// src/messages.ts
// ⚖️ Position C — 시리즈 메타 원칙 일관
export const POSITION_C_FOOTER = `
━━━━━━━━━━━━━━━━━━━━━━━━━━━
이 봇은 분석 콘텐츠 보조 도구이며,
법률·의료·금융 등 전문 조언을 제공하지 않습니다.
중요한 결정은 전문가와 상의하시기 바랍니다.
━━━━━━━━━━━━━━━━━━━━━━━━━━━
`.trim();
export const SYSTEM_PROMPT = `
당신은 TSV 운영자의 보조 도구입니다.
[원칙]
- 사실 단일 출처 (확실하지 않은 사실 추측 X)
- 베팅·픽 추천 표현 X
- 법률·의료·금융 단정 표현 X (~할 수 있다, ~검토 권장)
- 사용자 질문에 정확하고 간결하게 답변
[톤]
- 한국어 기본
- 전문적이고 친근함
- 마크다운 활용 가능
[제약]
- 답변 길이 1500자 이내 (디스코드 메시지 한도 고려)
- 코드 블록 사용 권장
`.trim();
export const ERROR_MESSAGES = {
rate_limit: '⏰ 잠시 후 다시 시도해주세요. (rate limit)',
api_error: '⚠️ 일시적 오류가 발생했습니다.',
too_long: '📝 질문이 너무 깁니다. 1000자 이내로 줄여주세요.',
};
Anthropic API 호출을 담당하는 wrapper 파일입니다. rate limit·API 오류·질문 길이 초과를 각각 별도로 처리합니다.
// src/claude.ts
import Anthropic from '@anthropic-ai/sdk';
import { SYSTEM_PROMPT } from './messages.js';
const client = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
export async function askClaude(question: string): Promise<string> {
if (question.length > 1000) {
throw new Error('TOO_LONG');
}
try {
const response = await client.messages.create({
model: MODEL,
max_tokens: 1500,
system: SYSTEM_PROMPT,
messages: [
{ role: 'user', content: question }
],
});
// text 블록만 추출
const textBlock = response.content.find(b => b.type === 'text');
if (!textBlock || textBlock.type !== 'text') {
return '⚠️ 답변을 생성할 수 없습니다.';
}
return textBlock.text;
} catch (err: any) {
if (err.status === 429) throw new Error('RATE_LIMIT');
if (err.status >= 500) throw new Error('API_ERROR');
throw err;
}
}
모든 요청과 응답을 JSONL 형식으로 일별 파일에 기록합니다. timestamp·user·question·status 4 요소가 핵심입니다.
// src/logger.ts
import fs from 'fs';
import path from 'path';
const LOG_DIR = process.env.LOG_DIR || './logs';
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
export interface LogEntry {
timestamp: string;
user_id: string;
channel_id: string;
question: string;
response_length: number;
duration_ms: number;
status: 'success' | 'rate_limit' | 'too_long' | 'api_error';
}
export function logTrace(entry: LogEntry): void {
// 일별 파일 분리
const date = new Date().toISOString().slice(0, 10);
const filepath = path.join(LOG_DIR, `${date}.jsonl`);
const line = JSON.stringify(entry) + '\n';
fs.appendFileSync(filepath, line);
}
export function logError(error: Error, context: any): void {
const date = new Date().toISOString().slice(0, 10);
const filepath = path.join(LOG_DIR, `${date}-errors.jsonl`);
const entry = {
timestamp: new Date().toISOString(),
error: error.message,
stack: error.stack,
context,
};
fs.appendFileSync(filepath, JSON.stringify(entry) + '\n');
}
봇의 핵심 코드입니다. !ask 명령을 감지해서 Claude API를 호출하고, 응답에 ⚖️ Position C footer를 붙여 전송합니다. graceful shutdown도 구현합니다.
// src/bot.ts
import 'dotenv/config';
import { Client, GatewayIntentBits, Message } from 'discord.js';
import { askClaude } from './claude.js';
import { logTrace, logError, LogEntry } from './logger.js';
import { POSITION_C_FOOTER, ERROR_MESSAGES } from './messages.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const COMMAND_PREFIX = '!ask ';
client.once('ready', () => {
console.log(`✅ Bot ready: ${client.user?.tag}`);
});
client.on('messageCreate', async (msg: Message) => {
// 봇 자기 메시지 무시
if (msg.author.bot) return;
// 명령 prefix 체크
if (!msg.content.startsWith(COMMAND_PREFIX)) return;
const question = msg.content.slice(COMMAND_PREFIX.length).trim();
if (!question) {
await msg.reply('질문을 입력해주세요. 예: `!ask 안녕`');
return;
}
// 타이핑 표시
await msg.channel.sendTyping();
const start = Date.now();
let status: LogEntry['status'] = 'success';
let response = '';
try {
response = await askClaude(question);
// 디스코드 2000자 제한 (한 메시지)
const trimmed = response.length > 1900
? response.slice(0, 1900) + '...\n(답변 잘림)'
: response;
// ⚖️ Position C 본문 추가
const fullResponse = `${trimmed}\n\n${POSITION_C_FOOTER}`;
await msg.reply(fullResponse);
} catch (err: any) {
if (err.message === 'RATE_LIMIT') {
status = 'rate_limit';
await msg.reply(ERROR_MESSAGES.rate_limit);
} else if (err.message === 'TOO_LONG') {
status = 'too_long';
await msg.reply(ERROR_MESSAGES.too_long);
} else if (err.message === 'API_ERROR') {
status = 'api_error';
await msg.reply(ERROR_MESSAGES.api_error);
} else {
status = 'api_error';
await msg.reply(ERROR_MESSAGES.api_error);
logError(err, { user: msg.author.id, question });
}
}
// ★ LogOnTable 로깅
logTrace({
timestamp: new Date().toISOString(),
user_id: msg.author.id,
channel_id: msg.channel.id,
question,
response_length: response.length,
duration_ms: Date.now() - start,
status,
});
});
// ★ Graceful shutdown
process.on('SIGINT', () => {
console.log('🛑 SIGINT received, shutting down...');
client.destroy();
process.exit(0);
});
process.on('SIGTERM', () => {
console.log('🛑 SIGTERM received, shutting down...');
client.destroy();
process.exit(0);
});
client.login(process.env.DISCORD_TOKEN!);
TypeScript 컴파일 설정과 npm scripts를 구성합니다. "type": "module"이 핵심이에요.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
// package.json (scripts 추가)
{
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/bot.js",
"dev": "nodemon --exec ts-node src/bot.ts",
"lint": "echo 'Add eslint if needed'"
}
}
package.json에 "type": "module"을 설정하면 모든 import 경로 끝에 .js를 명시해야 합니다.
예: import { askClaude } from './claude.js'; (소스는 .ts지만 경로는 .js)
빌드 후 실행하고 디스코드 채널에서 테스트합니다. 로그 파일도 확인하세요.
# 빌드
npm run build
# 실행
npm start
# ✅ Bot ready: TSV Assistant#1234
# ★ 디스코드에서 테스트
# 봇 초대된 채널에서:
# !ask 안녕
# 봇 응답:
# 안녕하세요! 무엇을 도와드릴까요?
#
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 이 봇은 분석 콘텐츠 보조 도구이며,
# 법률·의료·금융 등 전문 조언을 제공하지 않습니다.
# 중요한 결정은 전문가와 상의하시기 바랍니다.
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 로그 확인
ls logs/
cat logs/2026-05-09.jsonl | head
# {"timestamp":"...","user_id":"...","question":"안녕",...}
!ask 안녕에 봇이 응답하면 성공입니다.
logs/ 폴더에 JSONL 파일이 생성되면 LogOnTable도 정상 작동하고 있어요.
자주 발생하는 문제와 해결 방법을 정리했습니다. 막히면 여기서 먼저 확인하세요.
[함정 1] MESSAGE CONTENT INTENT 활성화 안 됨
- 증상: 봇이
message.content가 비어있음 - 해결: Discord Developer Portal → Bot → Privileged Intents 모두 ON
[함정 2] dotenv 로딩 안 됨
- 증상:
process.env.DISCORD_TOKENundefined - 해결:
bot.ts첫 줄import 'dotenv/config';
[함정 3] ESM/CJS 충돌
- 증상:
SyntaxError: Cannot use import statement - 해결:
package.json"type": "module"+ 모든 import 끝.js명시
[함정 4] Anthropic API rate limit
- 증상: 429 에러 자주 발생
- 해결: 사용자 별 cooldown (5초) 추가
[함정 5] 봇 토큰 노출
- 증상: GitHub push 시 Discord 자동 무효화
- 해결:
.env절대 git push 금지 (.gitignore일관)
- 1️⃣ Node.js 22 LTS 설치 — NVM 권장, 버전 전환 쉬움
- 2️⃣ Discord 봇 토큰 + Privileged Intents — 3개 모두 ON 필수
- 3️⃣ 프로젝트 구조 — .env·gitignore·src·logs
- 4️⃣ ⚖️ Position C 본문 메시지 — 시리즈 메타 원칙 일관
- 5️⃣ Anthropic SDK wrapper — rate limit·API 오류·질문 길이 다층 처리
- 6️⃣ LogOnTable 로깅 — 일별 JSONL, 4 요소 (timestamp·user·question·status)
- 7️⃣ 메인 봇 코드 — graceful shutdown 포함
- 8️⃣ 현재 상태: 봇 작동 ✅ (종료 시 봇 중단 — 6편 systemd에서 해결)
Node.js + discord.js + Anthropic SDK = !ask 명령 봇 첫 작동.
시리즈 메타 원칙 (⚖️ Position C · LogOnTable · 다층 보호) 일관 적용이 핵심이에요.
다음 편: 5편 — Python + python-telegram-bot 봇 boilerplate (Telegram)