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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`), 관리자 페이지 "알림 로그" 탭에서 조회 (타입/소스/대상/상태 필터 + 무한 스크롤)
Expand All @@ -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)

Expand Down
36 changes: 27 additions & 9 deletions packages/bot/src/schedulers/popular-posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}회차 인기 포스트 조회 중...`);
Expand Down
39 changes: 29 additions & 10 deletions packages/bot/src/schedulers/round-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}회차 리포트 생성 중...`);
Expand Down
16 changes: 14 additions & 2 deletions packages/web/src/app/api/board/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/lib/discord-notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down
Loading