diff --git a/packages/bot/src/bot.ts b/packages/bot/src/bot.ts index 8490607..17a8ed0 100644 --- a/packages/bot/src/bot.ts +++ b/packages/bot/src/bot.ts @@ -9,8 +9,8 @@ export function createBotClient(): Client { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.DirectMessages, - // MessageContent Intent는 Discord Developer Portal에서 활성화 필요 - // 임시로 비활성화 (활성화 후 아래 주석 제거) + // MessageContent Intent 없이 버튼/인터랙션 방식으로 동작 + // 봇이 100개 미만 서버라 Intent 활성화 불가능 // GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMessageReactions, ], diff --git a/packages/bot/src/handlers/dm-handler.ts b/packages/bot/src/handlers/dm-handler.ts index 09f5672..352c7a1 100644 --- a/packages/bot/src/handlers/dm-handler.ts +++ b/packages/bot/src/handlers/dm-handler.ts @@ -1,120 +1,138 @@ /** * DM Handler - * 벌금 납부 확인을 위한 DM 응답 처리 + * 벌금 납부 확인을 위한 버튼 기반 상호작용 처리 * Requirements: 8.2 + * MessageContent Intent 없이 동작하도록 버튼/인터랙션 방식 사용 + * P0 #9 해결: 인메모리 Map → DB 영속화로 변경 */ -import { Message, Client, Events } from 'discord.js'; +import { Client, Events, ButtonBuilder, ButtonStyle, ActionRowBuilder, Interaction, ChannelType } from 'discord.js'; +import { getDb, fines } from '@blog-study/shared/db'; +import { eq } from 'drizzle-orm'; import { getFineService, - isPaymentConfirmation, formatFineReason, } from '../services'; -/** - * Track pending fine confirmations - * Maps Discord user ID to their pending fine IDs - */ -const pendingConfirmations = new Map(); - /** * Add a pending fine confirmation for a user + * DB의 pendingConfirmation 컬럼을 true로 설정 */ -export function addPendingConfirmation(discordId: string, fineId: string): void { - const existing = pendingConfirmations.get(discordId) || []; - if (!existing.includes(fineId)) { - existing.push(fineId); - pendingConfirmations.set(discordId, existing); +export async function addPendingConfirmation(_discordId: string, fineId: string): Promise { + const db = getDb(); + try { + await db + .update(fines) + .set({ pendingConfirmation: true }) + .where(eq(fines.id, fineId)); + } catch (error) { + console.error(`❌ Failed to add pending confirmation for fine ${fineId}:`, error); } } /** * Remove a pending fine confirmation for a user + * DB의 pendingConfirmation 컬럼을 false로 설정 */ -export function removePendingConfirmation(discordId: string, fineId: string): void { - const existing = pendingConfirmations.get(discordId) || []; - const filtered = existing.filter(id => id !== fineId); - if (filtered.length > 0) { - pendingConfirmations.set(discordId, filtered); - } else { - pendingConfirmations.delete(discordId); +export async function removePendingConfirmation(_discordId: string, fineId: string): Promise { + const db = getDb(); + try { + await db + .update(fines) + .set({ pendingConfirmation: false }) + .where(eq(fines.id, fineId)); + } catch (error) { + console.error(`❌ Failed to remove pending confirmation for fine ${fineId}:`, error); } } /** - * Get pending fine confirmations for a user - */ -export function getPendingConfirmations(discordId: string): string[] { - return pendingConfirmations.get(discordId) || []; -} - -/** - * Clear all pending confirmations for a user + * Check if a fine has pending confirmation */ -export function clearPendingConfirmations(discordId: string): void { - pendingConfirmations.delete(discordId); +async function isPendingConfirmation(fineId: string): Promise { + const db = getDb(); + const [fine] = await db + .select({ pendingConfirmation: fines.pendingConfirmation }) + .from(fines) + .where(eq(fines.id, fineId)) + .limit(1); + return fine?.pendingConfirmation || false; } /** - * Handle DM message for fine payment confirmation - * Requirements: 8.2 - Parse confirmation words and update fine status + * Handle button interaction for fine payment confirmation + * Requirements: 8.2 - Handle button click to confirm payment + * MessageContent Intent 없이 동작 - 버튼 인터랙션 사용 */ -async function handleDMMessage(message: Message): Promise { - // Ignore bot messages - if (message.author.bot) { +async function handleButtonInteraction(interaction: Interaction): Promise { + // Only handle button interactions + if (!interaction.isButton()) { return; } - // Only process DMs - if (message.guild) { + // Only handle interactions in DMs + if (!interaction.channel || interaction.channel.type !== ChannelType.DM) { return; } - const discordId = message.author.id; - const pendingFineIds = getPendingConfirmations(discordId); - - // If no pending confirmations, ignore - if (pendingFineIds.length === 0) { - return; - } + const customId = interaction.customId; - // Check if message contains confirmation words - if (!isPaymentConfirmation(message.content)) { + // Check if this is a payment confirmation button + if (!customId.startsWith('confirm_payment_')) { return; } - const fineService = getFineService(); + const fineId = customId.replace('confirm_payment_', ''); + const discordId = interaction.user.id; - // Mark all pending fines as paid - const paidFines: string[] = []; - for (const fineId of pendingFineIds) { + // Verify this fine is pending for this user (DB 조회) + const isPending = await isPendingConfirmation(fineId); + if (!isPending) { try { - await fineService.markPaid(fineId); - paidFines.push(fineId); - removePendingConfirmation(discordId, fineId); - - console.log(`✅ Fine ${fineId} marked as paid for user ${discordId}`); + await interaction.reply({ + content: '❌ 이 벌금은 이미 처리되었거나 유효하지 않습니다.', + ephemeral: true, + }); } catch (error) { - console.error(`❌ Failed to mark fine ${fineId} as paid:`, error); + console.error('❌ Failed to send error reply:', error); } + return; } - // Send confirmation message - if (paidFines.length > 0) { + const fineService = getFineService(); + + try { + await fineService.markPaid(fineId); + await removePendingConfirmation(discordId, fineId); + + console.log(`✅ Fine ${fineId} marked as paid for user ${discordId}`); + try { - await message.reply({ - content: `✅ 벌금 납부가 확인되었습니다! (${paidFines.length}건)\n감사합니다. 🙏`, + await interaction.reply({ + content: '✅ 벌금 납부가 확인되었습니다! 감사합니다. 🙏', + ephemeral: false, }); } catch (error) { console.error('❌ Failed to send confirmation reply:', error); } + } catch (error) { + console.error(`❌ Failed to mark fine ${fineId} as paid:`, error); + try { + await interaction.reply({ + content: '❌ 납부 처리 중 오류가 발생했습니다. 관리자에게 문의해주세요.', + ephemeral: true, + }); + } catch (replyError) { + console.error('❌ Failed to send error reply:', replyError); + } } } /** - * Send fine notification DM to a user + * Send fine notification DM to a user with payment confirmation button * Requirements: 8.1 - Send DM with fine amount, reason, and payment instructions + * MessageContent Intent 없이 동작 - 버튼 사용 */ export async function sendFineNotification( client: Client, @@ -140,15 +158,26 @@ export async function sendFineNotification( `💰 **금액**: ${amount.toLocaleString()}원`, `📝 **사유**: ${reason}`, ``, - `납부 완료 후 이 메시지에 "납부완료" 또는 "완료"라고 답장해주세요.`, - `(영어로 "yes", "done", "paid"도 가능합니다)`, + `납부 완료 후 아래 버튼을 클릭해주세요.`, ].join('\n'); - await user.send(message); - - // Track pending confirmation - addPendingConfirmation(discordId, fineId); - + // Create payment confirmation button + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`confirm_payment_${fineId}`) + .setLabel('✅ 납부 완료') + .setStyle(ButtonStyle.Success) + ); + + await user.send({ + content: message, + components: [row], + }); + + // Track pending confirmation in DB + await addPendingConfirmation(discordId, fineId); + console.log(`📤 Fine notification sent to ${discordId} for fine ${fineId}`); return true; } catch (error) { @@ -158,8 +187,9 @@ export async function sendFineNotification( } /** - * Send fine reminder DM to a user + * Send fine reminder DM to a user with payment confirmation button * Requirements: 8.4 - Send reminder for unpaid fines + * MessageContent Intent 없이 동작 - 버튼 사용 */ export async function sendFineReminder( client: Client, @@ -186,14 +216,26 @@ export async function sendFineReminder( ``, `💰 **금액**: ${amount.toLocaleString()}원`, ``, - `납부 완료 후 이 메시지에 "납부완료" 또는 "완료"라고 답장해주세요.`, + `납부 완료 후 아래 버튼을 클릭해주세요.`, ].join('\n'); - await user.send(message); - - // Ensure pending confirmation is tracked - addPendingConfirmation(discordId, fineId); - + // Create payment confirmation button + const row = new ActionRowBuilder() + .addComponents( + new ButtonBuilder() + .setCustomId(`confirm_payment_${fineId}`) + .setLabel('✅ 납부 완료') + .setStyle(ButtonStyle.Success) + ); + + await user.send({ + content: message, + components: [row], + }); + + // Ensure pending confirmation is tracked in DB + await addPendingConfirmation(discordId, fineId); + console.log(`📤 Fine reminder sent to ${discordId} for fine ${fineId}`); return true; } catch (error) { @@ -204,15 +246,18 @@ export async function sendFineReminder( /** * Setup DM handler for the bot client + * MessageContent Intent 없이 버튼 인터랙션으로 동작 + * P0 #9 해결: 인메모리 Map → DB 영속화로 변경 */ export function setupDMHandler(client: Client): void { - client.on(Events.MessageCreate, async (message) => { + // Listen for button interactions + client.on(Events.InteractionCreate, async (interaction) => { try { - await handleDMMessage(message); + await handleButtonInteraction(interaction); } catch (error) { - console.error('❌ Error handling DM message:', error); + console.error('❌ Error handling button interaction:', error); } }); - console.log('📬 DM handler setup complete'); + console.log('📬 DM handler setup complete (button-based, DB-persistent)'); } diff --git a/packages/bot/src/services/fine.service.ts b/packages/bot/src/services/fine.service.ts index 7dd0bc3..c7c841f 100644 --- a/packages/bot/src/services/fine.service.ts +++ b/packages/bot/src/services/fine.service.ts @@ -27,6 +27,8 @@ export const FineAmounts = { /** * Confirmation words for payment (Korean and English) + * @deprecated 버튼 기반으로 변경되어 더 이상 사용되지 않음 (26-03-10) + * 이전 텍스트 파싱 방식에서 사용되던 상수로, 테스트 호환성을 위해 유지 */ export const PaymentConfirmationWords = [ 'yes', @@ -68,11 +70,12 @@ export class FineError extends Error { /** * Check if a message contains payment confirmation words + * @deprecated 버튼 기반으로 변경되어 더 이상 사용되지 않음 (26-03-10) * Requirements: 8.2 - Parse confirmation words from DM reply */ export function isPaymentConfirmation(message: string): boolean { const normalizedMessage = message.toLowerCase().trim(); - return PaymentConfirmationWords.some(word => + return PaymentConfirmationWords.some(word => normalizedMessage.includes(word.toLowerCase()) ); } diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 556145f..7287fd3 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -200,10 +200,12 @@ export const fines = pgTable( status: varchar('status', { length: 20 }).notNull().default(FineStatus.UNPAID), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), paidAt: timestamp('paid_at', { withTimezone: true }), + pendingConfirmation: boolean('pending_confirmation').default(true), }, (table) => ({ memberRoundUnique: uniqueIndex('fines_member_round_unique').on(table.memberId, table.roundId), statusIdx: index('idx_fines_status').on(table.status), + pendingConfirmationIdx: index('idx_fines_pending_confirmation').on(table.pendingConfirmation), }) );