Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/bot/src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
Expand Down
205 changes: 125 additions & 80 deletions packages/bot/src/handlers/dm-handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>();

/**
* 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<void> {
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<void> {
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<boolean> {
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<void> {
// Ignore bot messages
if (message.author.bot) {
async function handleButtonInteraction(interaction: Interaction): Promise<void> {
// 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,
Expand All @@ -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<ButtonBuilder>()
.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) {
Expand All @@ -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,
Expand All @@ -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<ButtonBuilder>()
.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) {
Expand All @@ -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)');
}
5 changes: 4 additions & 1 deletion packages/bot/src/services/fine.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const FineAmounts = {

/**
* Confirmation words for payment (Korean and English)
* @deprecated 버튼 기반으로 변경되어 더 이상 사용되지 않음 (26-03-10)
* 이전 텍스트 파싱 방식에서 사용되던 상수로, 테스트 호환성을 위해 유지
*/
export const PaymentConfirmationWords = [
'yes',
Expand Down Expand Up @@ -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())
);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
);

Expand Down