From 9ff5f4d6ae0f4eafb7aa4ac065c5391ec8ce28a6 Mon Sep 17 00:00:00 2001 From: bbbang105 <2018111366@dgu.ac.kr> Date: Tue, 14 Apr 2026 11:14:16 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=20Discord=20embed=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=ED=8F=AC=ED=95=A8=20+=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=A4=91=EB=B3=B5=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공지 알림: 제목만 보내던 Discord 메시지를 embed(제목+본문 500자 미리보기)로 변경 - 인기 포스트/회차 리포트: grace period 종료 후 4일 윈도우 가드 추가 (2주 주기 회차에서 중복 발송 방지) - escapeDiscordMarkdown export 및 embed 콘텐츠에 적용 Co-Authored-By: Claude --- CLAUDE.md | 4 +- packages/bot/src/schedulers/popular-posts.ts | 36 ++++++++++++----- packages/bot/src/schedulers/round-reporter.ts | 39 ++++++++++++++----- packages/web/src/app/api/board/route.ts | 16 +++++++- packages/web/src/lib/discord-notify.ts | 2 +- 5 files changed, 73 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24b040e..2d69f1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **포스트 수동등록**: 2단계 UX (URL→미리보기→편집→등록), OG HTML 엔티티 자동 디코딩, Discord 알림 토글, 푸시 알림은 Discord 토글과 무관하게 항상 발송 - **새 글 푸시 알림**: 수동 등록 + RSS 수집 모두 지원. 대상: active/OB/dormant (작성자 본인 제외), 알림 타입 `new_post`. 봇→웹 내부 API(`/api/internal/new-post-push`, Bearer 인증) 경유 - **포스트 수정**: 본인 또는 관리자만 제목/설명 수정 가능 (`PATCH /api/posts/[id]`) -- **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + 웹 딥링크 버튼 +- **공지 알림**: 게시판 공지 작성 시 FCM 푸시 + Discord 공지채널(`notice_channel_id`) `@everyone` + embed(제목+본문 미리보기 500자) + 웹 딥링크 버튼 - **벌금 DM**: 계좌 정보 포함 (3333333114501 카카오뱅크), 납부완료 시 관리자 채널 알림 - **D-Day 계산**: KST 캘린더 날짜 기준 (midnight 비교, 당일=D-Day=0), 제출률은 active 유저만 카운트 - **Discord 알림 로그**: `discord_notification_logs` 테이블에 봇/웹 모든 채널+DM 알림 성공/실패 기록, `logNotification()` 헬퍼 (봇: `notification-logger.ts`, 웹: `notification-log.ts`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤) @@ -82,7 +82,7 @@ pnpm --filter @blog-study/bot rss-collect # 수동 RSS 수집 (봇 없이) - **포스트 삭제**: 본인 또는 관리자만 가능, 트랜잭션으로 댓글/조회기록/활동점수(blog_post) 일괄 삭제 - **이모지 리액션**: 게시판 글 + 포스트에 고정 6종 이모지 (👍👀🔥💡😂✅) 토글, `ReactionBar` 공용 컴포넌트 (`apiPath` prop으로 board/posts 구분), 호버(PC)/클릭(모바일) 시 닉네임 팝오버, 복수 선택 가능, 활동 점수/알림 없음 - **인기글 점수**: `댓글×3 + 조회수×2 + 리액션×1`, 인기순 상위 5개 메달 테두리 (금/은/동/스카이블루/라벤더) -- **인기 포스트 알림**: 화 08:05 KST 자동 + 수동 트리거, 이전 회차 TOP 5 Discord Embed (이모지별 카운트, 썸네일, 링크 버튼), `popular_posts_channel_id` 설정 필요 +- **인기 포스트 알림**: 화 08:05 KST 자동 + 수동 트리거, 이전 회차 TOP 5 Discord Embed (이모지별 카운트, 썸네일, 링크 버튼), `popular_posts_channel_id` 설정 필요, grace period 종료 후 4일 이내만 자동 발송 (중복 방지) - **포스트 회차 필터**: 전체/회차별 셀렉트 드롭다운, `/api/rounds`에서 동적 조회, 인기순도 선택 회차 기준 - **스터디원 목록**: active + dormant + ob 모두 표시, 상태 칩으로 구분 (OB: 황금 파스텔, 휴면: secondary) diff --git a/packages/bot/src/schedulers/popular-posts.ts b/packages/bot/src/schedulers/popular-posts.ts index ce7cd55..4b4f3bd 100644 --- a/packages/bot/src/schedulers/popular-posts.ts +++ b/packages/bot/src/schedulers/popular-posts.ts @@ -195,15 +195,33 @@ export class PopularPosts { } // grace period 체크 (수동/특정 회차 지정 시 건너뜀) - if (!force && !forceRoundNumber && !isGracePeriodEnded(targetRound)) { - logger.info(`🏆 [인기 포스트] ${targetRound.roundNumber}회차 유예 기간 미종료, 건너뜀`); - return { - timestamp: startTime, - sent: false, - roundNumber: targetRound.roundNumber, - postCount: 0, - errors: ['유예 기간 미종료'], - }; + if (!force && !forceRoundNumber) { + if (!isGracePeriodEnded(targetRound)) { + logger.info(`🏆 [인기 포스트] ${targetRound.roundNumber}회차 유예 기간 미종료, 건너뜀`); + return { + timestamp: startTime, + sent: false, + roundNumber: targetRound.roundNumber, + postCount: 0, + errors: ['유예 기간 미종료'], + }; + } + + // 이미 보고된 회차 중복 발송 방지: grace period 종료 후 4일 이내만 발송 + const graceEndMs = new Date(targetRound.graceEndDate + 'T23:59:59+09:00').getTime(); + const daysSinceGraceEnd = (Date.now() - graceEndMs) / (1000 * 60 * 60 * 24); + if (daysSinceGraceEnd > 4) { + logger.info( + `🏆 [인기 포스트] ${targetRound.roundNumber}회차 유예 기간이 ${Math.floor(daysSinceGraceEnd)}일 전 종료됨, 이미 발송된 것으로 간주하여 건너뜀` + ); + return { + timestamp: startTime, + sent: false, + roundNumber: targetRound.roundNumber, + postCount: 0, + errors: ['이미 발송된 회차 (유예 기간 종료 후 4일 초과)'], + }; + } } logger.info(`🏆 [인기 포스트] ${targetRound.roundNumber}회차 인기 포스트 조회 중...`); diff --git a/packages/bot/src/schedulers/round-reporter.ts b/packages/bot/src/schedulers/round-reporter.ts index 2e6eed5..0ef942d 100644 --- a/packages/bot/src/schedulers/round-reporter.ts +++ b/packages/bot/src/schedulers/round-reporter.ts @@ -128,16 +128,35 @@ export class RoundReporter { } // grace period 체크 (수동 트리거 시 건너뜀) - if (!force && !isGracePeriodEnded(prevRound)) { - logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 지각 기간 미종료, 건너뜀`); - return { - timestamp: startTime, - roundNumber: prevRound.roundNumber, - reportSent: false, - newRoundStarted: false, - newRoundNumber: null, - errors: ['지각 기간 미종료'], - }; + if (!force) { + if (!isGracePeriodEnded(prevRound)) { + logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 지각 기간 미종료, 건너뜀`); + return { + timestamp: startTime, + roundNumber: prevRound.roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: ['지각 기간 미종료'], + }; + } + + // 이미 보고된 회차 중복 발송 방지: grace period 종료 후 4일 이내만 발송 + const graceEndMs = new Date(prevRound.graceEndDate + 'T23:59:59+09:00').getTime(); + const daysSinceGraceEnd = (Date.now() - graceEndMs) / (1000 * 60 * 60 * 24); + if (daysSinceGraceEnd > 4) { + logger.info( + `📊 [회차 리포트] ${prevRound.roundNumber}회차 유예 기간이 ${Math.floor(daysSinceGraceEnd)}일 전 종료됨, 이미 발송된 것으로 간주하여 건너뜀` + ); + return { + timestamp: startTime, + roundNumber: prevRound.roundNumber, + reportSent: false, + newRoundStarted: false, + newRoundNumber: null, + errors: ['이미 발송된 회차 (유예 기간 종료 후 4일 초과)'], + }; + } } logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 리포트 생성 중...`); diff --git a/packages/web/src/app/api/board/route.ts b/packages/web/src/app/api/board/route.ts index 82fd43f..f6ba4c9 100644 --- a/packages/web/src/app/api/board/route.ts +++ b/packages/web/src/app/api/board/route.ts @@ -15,7 +15,7 @@ import { isValidCategory } from '@/lib/board-config'; import { sanitizeDescription, sanitizeTiptapContent } from '@/lib/sanitize'; import { grantWebScore } from '@/lib/score'; import { sendPushToMembers } from '@/lib/push'; -import { sendDiscordChannelMessage } from '@/lib/discord-notify'; +import { escapeDiscordMarkdown, sendDiscordChannelMessage } from '@/lib/discord-notify'; import { logNotification } from '@/lib/notification-log'; const { @@ -298,10 +298,22 @@ export async function POST(request: NextRequest) { const channelId = channelRow?.value; if (channelId) { const postUrl = `https://kusting-web.vercel.app/board/${result.id}`; + const trimmedContent = contentText?.trim() ?? ''; + const previewText = escapeDiscordMarkdown(trimmedContent.slice(0, 500)); const discordResult = await sendDiscordChannelMessage({ channelId, allowEveryone: true, - content: `@everyone\n\n📢 **새로운 공지사항이 등록되었습니다!**\n\n## ${title.trim().slice(0, 100)}`, + content: '@everyone', + embeds: [ + { + title: `📢 ${escapeDiscordMarkdown(title.trim()).slice(0, 100)}`, + description: previewText + ? `${previewText}${trimmedContent.length > 500 ? '\n\n…' : ''}` + : undefined, + color: 0x0091ff, + timestamp: new Date().toISOString(), + }, + ], components: [ { type: 1, diff --git a/packages/web/src/lib/discord-notify.ts b/packages/web/src/lib/discord-notify.ts index bdf26a4..ee47663 100644 --- a/packages/web/src/lib/discord-notify.ts +++ b/packages/web/src/lib/discord-notify.ts @@ -50,7 +50,7 @@ interface SendChannelMessageOptions { * Discord Markdown 특수문자 이스케이프 * 사용자 입력을 embed에 넣기 전에 적용하여 마크다운 인젝션 방지 */ -function escapeDiscordMarkdown(text: string): string { +export function escapeDiscordMarkdown(text: string): string { return text.replace(/([*_~|`>[\]()@\\])/g, '\\$1'); }