diff --git a/.claude/skills/events-handler/config.json b/.claude/skills/events-handler/config.json new file mode 100644 index 0000000..1247ceb --- /dev/null +++ b/.claude/skills/events-handler/config.json @@ -0,0 +1,15 @@ +{ + "name": "events-handler", + "description": "Discord.js 이벤트 처리 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["discord", "events", "handlers", "reference"], + "keywords": ["discord.js", "events", "handlers", "patterns", "examples"], + "category": "reference", + "patterns": [ + "event-handler-registration", + "error-handling", + "message-handling", + "reaction-handling" + ] +} diff --git a/.claude/skills/events-handler/prompt.md b/.claude/skills/events-handler/prompt.md new file mode 100644 index 0000000..610da10 --- /dev/null +++ b/.claude/skills/events-handler/prompt.md @@ -0,0 +1,149 @@ +# Events Handler Reference + +Discord.js v14 이벤트 처리 예시 모음 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 이벤트 핸들러 구현 시 참고 + +## 기본 패턴 (현재 프로젝트 방식) + +```typescript +import type { Client, Message } from 'discord.js'; +import { Events } from 'discord.js'; + +/** + * 커스텀 이벤트 핸들러 등록 + */ +export function setupCustomHandler(client: Client): void { + // 메시지 생성 이벤트 + client.on(Events.MessageCreate, async (message: Message) => { + try { + // 봇 메시지 무시 + if (message.author.bot) return; + + // DM 무시 + if (!message.guild) return; + + // 처리 로직 + console.log(`[CustomHandler] Message from ${message.author.id}`); + } catch (error) { + console.error('[CustomHandler] Error:', error); + } + }); + + console.log('✅ Custom handler registered'); +} +``` + +## 주요 이벤트 타입 + +### 1. 봇 시작 (ClientReady) +```typescript +client.once(Events.ClientReady, (readyClient) => { + console.log(`✅ Bot logged in as ${readyClient.user.tag}`); + console.log(`📊 Serving ${readyClient.guilds.cache.size} guild(s)`); +}); +``` + +### 2. 메시지 생성 (MessageCreate) +```typescript +client.on(Events.MessageCreate, async (message: Message) => { + if (message.author.bot) return; + if (!message.guild) return; + + // 메시지 처리 +}); +``` + +### 3. 리액션 추가 (MessageReactionAdd) +```typescript +client.on(Events.MessageReactionAdd, async (reaction, user) => { + if (user.bot) return; + + // partial인 경우 fetch + if (reaction.partial) { + await reaction.fetch(); + } + + // 리액션 처리 +}); +``` + +### 4. 리액션 제거 (MessageReactionRemove) +```typescript +client.on(Events.MessageReactionRemove, async (reaction, user) => { + if (user.bot) return; + + // 리액션 제거 처리 +}); +``` + +### 5. 음성 상태 변경 (VoiceStateUpdate) +```typescript +client.on(Events.VoiceStateUpdate, (oldState, newState) => { + // 음성 채널 입장/퇴장 처리 +}); +``` + +### 6. 멤버 입장 (GuildMemberAdd) +```typescript +client.on(Events.GuildMemberAdd, (member) => { + // 새 멤버 환영 +}); +``` + +### 7. 에러 처리 (Error) +```typescript +client.on(Events.Error, (error) => { + console.error('❌ Discord client error:', error); +}); +``` + +## 등록 방법 (index.ts) + +```typescript +import { createBotClient, setupEventHandlers } from './bot'; +import { setupActivityHandler } from './handlers/activity-handler'; +import { setupCustomHandler } from './handlers/custom-handler'; + +async function main(): Promise { + const client = createBotClient(); + + // 기본 이벤트 핸들러 + setupEventHandlers(client); + + // 활동 점수 핸들러 + setupActivityHandler(client); + + // 커스텀 핸들러 + setupCustomHandler(client); + + await startBot(client, env.DISCORD_TOKEN); +} +``` + +## 에러 처리 패턴 + +모든 이벤트 핸들러는 try-catch로 감싸야 합니다: + +```typescript +client.on(Events.SomeEvent, async (...args) => { + try { + // 이벤트 처리 로직 + } catch (error) { + console.error('[HandlerName] Error:', error); + // 필요시 로깅 또는 알림 + } +}); +``` + +## 프로젝트에서 사용 중인 핸들러 + +- `setupEventHandlers()` - 기본 이벤트 (ready, error, warn) +- `setupActivityHandler()` - 활동 점수 (message, reaction) +- `setupDMHandler()` - DM 처리 (벌금 납부 확인) + +## 참고 + +- Discord.js v14.25.1 Events: https://discord.js.org/docs/packages/discord.js/14.25.1/Classes/Client +- 현재 프로젝트: `packages/bot/src/handlers/` 참고 diff --git a/.claude/skills/scheduler-reference/config.json b/.claude/skills/scheduler-reference/config.json new file mode 100644 index 0000000..b456709 --- /dev/null +++ b/.claude/skills/scheduler-reference/config.json @@ -0,0 +1,15 @@ +{ + "name": "scheduler-reference", + "description": "pg-boss 스케줄러 구현 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["scheduler", "pg-boss", "cron", "jobs", "reference"], + "keywords": ["pg-boss", "scheduler", "cron", "worker", "singleton"], + "category": "reference", + "patterns": [ + "singleton-pattern", + "isRunning-flag", + "cron-scheduling", + "worker-registration" + ] +} diff --git a/.claude/skills/scheduler-reference/prompt.md b/.claude/skills/scheduler-reference/prompt.md new file mode 100644 index 0000000..474419c --- /dev/null +++ b/.claude/skills/scheduler-reference/prompt.md @@ -0,0 +1,183 @@ +# Scheduler Reference + +pg-boss 기반 스케줄러 구현 참고 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 스케줄러 구현 시 참고 + +## 기본 패턴 + +```typescript +/** + * 스케줄러 클래스 템플릿 + */ +export class MyScheduler { + private isRunning = false; + + /** + * 실행 중인지 확인 + */ + isChecking(): boolean { + return this.isRunning; + } + + /** + * 작업 실행 + */ + async run(): Promise { + if (this.isRunning) { + console.log('[MyScheduler] Already in progress, skipping'); + return { + timestamp: new Date(), + errors: ['Already in progress'], + }; + } + + this.isRunning = true; + + try { + // 작업 로직 구현 + console.log('[MyScheduler] Running...'); + + return { + timestamp: new Date(), + success: true, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[MyScheduler] Error: ${errorMsg}`); + + return { + timestamp: new Date(), + errors: [errorMsg], + }; + } finally { + this.isRunning = false; + } + } +} + +// Singleton 인스턴스 +let instance: MyScheduler | null = null; + +export function getMyScheduler(): MyScheduler { + if (!instance) { + instance = new MyScheduler(); + } + return instance; +} + +export function resetMyScheduler(): void { + instance = null; +} +``` + +## scheduler-registry.ts에 등록 + +```typescript +import { getMyScheduler } from './schedulers/my-scheduler'; + +export async function registerAllJobs(boss: PgBoss, client: Client): Promise { + const myScheduler = getMyScheduler(); + + // 1. Cron 잡 등록 (매일 09:00) + await boss.schedule('my-job', '0 9 * * *'); + console.log(' 📅 Scheduled: my-job (0 9 * * *)'); + + // 2. 워커 등록 + await boss.work('my-job', { batchSize: 1 }, async () => { + await myScheduler.run(); + }); + + console.log(`✅ All jobs registered`); +} +``` + +## Discord 클라이언트가 필요한 경우 + +```typescript +export class MyScheduler { + private client: Client | null = null; + + setClient(client: Client): void { + this.client = client; + } + + async run(): Promise { + if (!this.client) { + console.error('[MyScheduler] Discord client not set'); + return { + timestamp: new Date(), + errors: ['Discord client not set'], + }; + } + + // client 사용 + const channel = await this.client.channels.fetch('CHANNEL_ID'); + // ... + } +} + +// 등록 시 +const myScheduler = getMyScheduler(); +myScheduler.setClient(client); +``` + +## Cron 표현식 예시 + +| 표현식 | 설명 | +|---------|------| +| `*/5 * * * *` | 5분마다 | +| `0 9 * * *` | 매일 09:00 | +| `0 0 * * 2` | 매주 화요일 00:00 | +| `0 23 * * *` | 매일 23:00 (KST 익일 08:00) | +| `0 0 1 * *` | 매월 1일 00:00 | + +## pg-boss 설정 + +```typescript +import { PgBoss } from 'pg-boss'; + +// 시작 +const boss = new PgBoss(connectionString); +boss.on('error', (error: Error) => console.error('[pg-boss] Error:', error)); +await boss.start(); + +// Cron 잡 등록 +await boss.schedule('job-name', 'cron-expression'); + +// 워커 등록 +await boss.work('job-name', { batchSize: 1 }, async () => { + // 작업 실행 +}); + +// 종료 +await boss.stop({ graceful: true, timeout: 30000 }); +``` + +## 결과 인터페이스 + +```typescript +interface Result { + timestamp: Date; + success?: boolean; + errors: string[]; + // 기타 필요한 필드 +} +``` + +## 프로젝트 예시 참고 + +- `rss-poller.ts` - RSS 폴링 (5분) +- `attendance-checker.ts` - 출석 체크 (주간) +- `fine-reminder.ts` - 벌금 리마인더 (일일) +- `round-reporter.ts` - 회차 리포트 (주간) +- `curation-crawler.ts` - 큐레이션 크롤링 (일일) + +## 주의사항 + +1. **동시 실행 방지**: `isRunning` 플래그로 중복 실행 방지 +2. **에러 처리**: 모든 에러를 catch하고 결과 객체에 포함 +3. **로그**: 진행 상황을 console.log로 출력 +4. **Singleton**: 한 인스턴스만 사용 (`getXxx()` 함수) +5. **테스트용 reset**: `resetXxx()` 함수 제공 diff --git a/.claude/skills/service-pattern/config.json b/.claude/skills/service-pattern/config.json new file mode 100644 index 0000000..db51ca9 --- /dev/null +++ b/.claude/skills/service-pattern/config.json @@ -0,0 +1,16 @@ +{ + "name": "service-pattern", + "description": "DB 서비스 계층 구현 참고 (현재 프로젝트 패턴 기반)", + "version": "1.0.0", + "author": "Blog Study", + "tags": ["service", "database", "drizzle", "orm", "reference"], + "keywords": ["drizzle-orm", "service-layer", "crud", "singleton", "repository"], + "category": "reference", + "patterns": [ + "singleton-pattern", + "error-handling", + "crud-operations", + "complex-queries", + "transactions" + ] +} diff --git a/.claude/skills/service-pattern/prompt.md b/.claude/skills/service-pattern/prompt.md new file mode 100644 index 0000000..1b580c5 --- /dev/null +++ b/.claude/skills/service-pattern/prompt.md @@ -0,0 +1,252 @@ +# Service Pattern Reference + +DB 서비스 계층 구현 참고 - 현재 프로젝트 패턴 기반 + +## 사용 방법 +새로운 서비스 구현 시 참고 + +## 기본 패턴 + +```typescript +import { eq } from 'drizzle-orm'; +import { getDb, myTable, type NewMyTable, type MyTable } from '@blog-study/shared/db'; + +/** + * 서비스 클래스 템플릿 + */ +export class MyService { + private db = getDb(); + + /** + * 생성 + */ + async create(input: CreateInput): Promise { + const newItem: NewMyTable = { + // 필드 매핑 + }; + + const [created] = await this.db.insert(myTable).values(newItem).returning(); + return created!; + } + + /** + * ID로 조회 + */ + async getById(id: string): Promise { + const [item] = await this.db + .select() + .from(myTable) + .where(eq(myTable.id, id)) + .limit(1); + + return item || null; + } + + /** + * 전체 조회 + */ + async getAll(): Promise { + return this.db.select().from(myTable); + } + + /** + * 업데이트 + */ + async update(id: string, data: Partial): Promise { + const [updated] = await this.db + .update(myTable) + .set(data) + .where(eq(myTable.id, id)) + .returning(); + + return updated!; + } + + /** + * 삭제 + */ + async delete(id: string): Promise { + await this.db.delete(myTable).where(eq(myTable.id, id)); + } +} + +// Singleton 인스턴스 +let instance: MyService | null = null; + +export function getMyService(): MyService { + if (!instance) { + instance = new MyService(); + } + return instance; +} + +export function resetMyService(): void { + instance = null; +} +``` + +## 에러 처리 패턴 + +```typescript +/** + * 에러 코드 정의 + */ +export const MyErrorCodes = { + NOT_FOUND: 'E0001', + INVALID_INPUT: 'E0002', + DUPLICATE: 'E0003', +} as const; + +/** + * 커스텀 에러 클래스 + */ +export class MyError extends Error { + constructor( + public code: string, + public userMessage: string, + message?: string + ) { + super(message || userMessage); + this.name = 'MyError'; + } +} + +// 사용 예시 +async getById(id: string): Promise { + const [item] = await this.db + .select() + .from(myTable) + .where(eq(myTable.id, id)) + .limit(1); + + if (!item) { + throw new MyError( + MyErrorCodes.NOT_FOUND, + '항목을 찾을 수 없습니다.' + ); + } + + return item; +} +``` + +## 복잡한 쿼리 패턴 + +```typescript +/** + * 조인 조회 + */ +async getWithMember(id: string): Promise { + const result = await this.db + .select({ + item: myTable, + memberName: members.name, + memberDiscordId: members.discordId, + }) + .from(myTable) + .innerJoin(members, eq(myTable.memberId, members.id)) + .where(eq(myTable.id, id)) + .limit(1); + + if (!result[0]) return null; + + return { + ...result[0].item, + memberName: result[0].memberName, + memberDiscordId: result[0].memberDiscordId, + }; +} + +/** + * 집계 쿼리 + */ +async getStats(): Promise { + const result = await this.db + .select({ + total: sql`COUNT(*)`, + sum: sql`COALESCE(SUM(${myTable.amount}), 0)`, + }) + .from(myTable); + + return { + total: Number(result[0]?.total ?? 0), + sum: Number(result[0]?.sum ?? 0), + }; +} + +/** + * 그룹화 + */ +async groupByMember(): Promise> { + return this.db + .select({ + memberId: myTable.memberId, + count: sql`COUNT(*)`, + }) + .from(myTable) + .groupBy(myTable.memberId); +} +``` + +## 트랜잭션 패턴 + +```typescript +import { db } from '@blog-study/shared/db'; + +async createWithRelated(input: CreateInput): Promise { + return db.transaction(async (tx) => { + // 1. 메인 항목 생성 + const [item] = await tx + .insert(myTable) + .values({ /* ... */ }) + .returning(); + + // 2. 연관 항목 생성 + await tx + .insert(relatedTable) + .values({ /* ... */ }); + + return item; + }); +} +``` + +## Property-Based Test 패턴 + +```typescript +import { describe, it } from 'vitest'; +import { fc } from 'fast-check'; + +describe('MyService Property Tests', () => { + it('Property 1: create generates valid ID', async () => { + await fc.assert( + fc.asyncProperty( + fc.string(), + async (name) => { + const service = getMyService(); + const result = await service.create({ name }); + + return !!result.id; + } + ), + { numRuns: 100 } + ); + }); +}); +``` + +## 프로젝트 예시 참고 + +- `score.service.ts` - 활동 점수 (일일 상한 체크, CTE) +- `post.service.ts` - 블로그 포스트 (중복 체크) +- `attendance.service.ts` - 출석 관리 (상태 전환) +- `fine.service.ts` - 벌금 관리 (납부/면제) +- `notification.service.ts` - 알림 발송 + +## 주의사항 + +1. **Singleton**: 한 인스턴스만 사용 (`getXxx()` 함수) +2. **DB 연결**: `getDb()` 사용 (Transaction Pooler) +3. **타입 안전성**: Drizzle ORM 타입 활용 +4. **에러 처리**: 커스텀 에러 클래스로 사용자 메시지 제공 +5. **테스트**: Property-Based Test 작성 (최소 100회)