diff --git a/api_client.py b/api_client.py index ff9016c..14418df 100644 --- a/api_client.py +++ b/api_client.py @@ -160,6 +160,13 @@ def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: starts_at_raw = _first(raw, "startsAt", "starts_at", "startDate", "start_date") starts_at = _parse_datetime(starts_at_raw) + created_at = _parse_datetime(_first(raw, "createdAt", "created_at")) + approved_at = _parse_datetime(_first(raw, "approvedAt", "approved_at")) + + approval_status = str( + _first(raw, "approvalStatus", "approval_status", default="PENDING") + ).upper() + lecture_id = _first(raw, "id", "lectureId") url_id = _first(raw, "lectureId", "id") @@ -188,13 +195,24 @@ def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: "target_grades": target_grades, "enrolled_count": enrolled_count, "lecture_url": f"{LECTURE_BASE_URL}/{url_id}" if url_id else None, + "created_at": created_at, + "approval_status": approval_status, + "approved_at": approved_at, } -def fetch_open_lectures() -> List[Dict[str, Any]]: +def fetch_all_lectures() -> List[Dict[str, Any]]: payload = _request_json(_with_query(LECTURES_API_URL, {"size": PAGE_SIZE})) - lectures = [_normalize(item) for item in _pick_lectures(payload)] - return [lec for lec in lectures if lec["status"] in OPEN_STATUSES] + return [_normalize(item) for item in _pick_lectures(payload)] + + +def fetch_open_lectures() -> List[Dict[str, Any]]: + lectures = fetch_all_lectures() + return [ + lec + for lec in lectures + if lec["status"] in OPEN_STATUSES and lec.get("approval_status") == "APPROVED" + ] def fetch_active_lectures() -> List[Dict[str, Any]]: diff --git a/bot.py b/bot.py index b2a42c1..7c21596 100644 --- a/bot.py +++ b/bot.py @@ -1,6 +1,6 @@ import asyncio import os -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from typing import Any, Dict, List, Optional, Union import discord @@ -10,12 +10,20 @@ from api_client import ( ApiError, + OPEN_STATUSES, fetch_active_lectures, + fetch_all_lectures, fetch_all_lectures_basic, fetch_enrollment_counts, - fetch_open_lectures, ) -from state_store import claim_notification, init_state_store, mark_notified +from state_store import ( + claim_notification, + get_due_open_notifications, + init_state_store, + mark_notified, + mark_open_notified, + schedule_open_notification, +) load_dotenv() @@ -62,6 +70,10 @@ def _parse_channel_role_map(env_val: str) -> Dict[int, int]: 2: int(os.getenv("GRADE2_ROLE_ID", "1334466986419163187")), } +STUDENT_COUNCIL_CHANNEL_ID = int( + os.getenv("STUDENT_COUNCIL_CHANNEL_ID", "1542518682880839680") +) + POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "30")) CONFIRMED_MIN = int(os.getenv("CONFIRMED_MIN", "10")) @@ -69,6 +81,10 @@ def _parse_channel_role_map(env_val: str) -> Dict[int, int]: EMBED_COLOR = 0xE8B84B FOOTER_TEXT = "GSM 릴스 봇" +KST = timezone(timedelta(hours=9)) +OPEN_HOUR = int(os.getenv("OPEN_HOUR", "16")) +OPEN_MINUTE = int(os.getenv("OPEN_MINUTE", "20")) + intents = discord.Intents.default() intents.message_content = True @@ -130,6 +146,16 @@ def _make_progress_bar(enrolled: int, capacity: int, width: int = 10) -> str: return "█" * filled + "░" * (width - filled) +def _compute_open_at_iso(approved_at_utc: datetime) -> str: + approved_kst = approved_at_utc.astimezone(KST) + open_kst = approved_kst.replace( + hour=OPEN_HOUR, minute=OPEN_MINUTE, second=0, microsecond=0 + ) + if approved_kst >= open_kst: + open_kst += timedelta(days=1) + return open_kst.astimezone(timezone.utc).isoformat() + + def _build_base_embed( title: str, lecture: Dict[str, Any], description: Optional[str] = None ) -> discord.Embed: @@ -169,6 +195,19 @@ def make_new_lecture_embed(lecture: Dict[str, Any]) -> discord.Embed: return embed +def make_submission_embed(lecture: Dict[str, Any]) -> discord.Embed: + """학생회용: 강연 신청서가 새로 접수됐을 때 보내는 임베드.""" + embed = _build_base_embed("📝새 강연 신청서가 접수됐어요!", lecture) + if lecture.get("lecture_url"): + embed.add_field( + name="신청서 링크", + value=f"👉[신청서 확인하러 가기]({lecture['lecture_url']})", + inline=False, + ) + embed.set_footer(text=FOOTER_TEXT) + return embed + + def make_confirmed_embed(lecture: Dict[str, Any], enrolled_count: int) -> discord.Embed: desc = ( f"**{lecture['title']}** 강연이 {CONFIRMED_MIN}명 이상 모여 개설 확정됐습니다!" @@ -184,6 +223,19 @@ def make_confirmed_embed(lecture: Dict[str, Any], enrolled_count: int) -> discor return embed +def make_open_embed(lecture: Dict[str, Any]) -> discord.Embed: + desc = f"**{lecture['title']}** 강연 신청이 시작됐습니다!" + embed = _build_base_embed("🔔신청이 시작됐어요!", lecture, description=desc) + if lecture.get("lecture_url"): + embed.add_field( + name="신청 링크", + value=f"👉[강연 신청하러 가기]({lecture['lecture_url']})", + inline=False, + ) + embed.set_footer(text=FOOTER_TEXT) + return embed + + def _get_static_channel_mention(channel_id: int) -> str: role_id = STATIC_NOTIFY_CHANNEL_ROLE_MAP.get(channel_id) if not role_id: @@ -254,7 +306,6 @@ async def send_to_all_notify_channels( async def send_confirmed_notification( lecture: Dict[str, Any], message: str, embed: discord.Embed ) -> None: - """개설 확정 알림 전용 발송 함수. 멘션 없이 메시지만 보낸다.""" channel_ids = set(STATIC_NOTIFY_CHANNEL_ROLE_MAP) | set( GRADE_AWARE_NOTIFY_CHANNEL_IDS ) @@ -272,10 +323,62 @@ async def send_confirmed_notification( ) +async def send_to_student_council(embed: discord.Embed) -> None: + channel = bot.get_channel(STUDENT_COUNCIL_CHANNEL_ID) + if channel: + try: + await channel.send(embed=embed) + except Exception as e: + print(f"[전송 에러] 학생회 채널로 메시지 전송 실패: {e}") + else: + print( + f"[채널 없음] {STUDENT_COUNCIL_CHANNEL_ID} — 봇이 이 채널을 못 찾음(권한/캐시 확인 필요)" + ) + + +async def _process_due_open_notifications(lectures: List[Dict[str, Any]]) -> None: + now_iso = datetime.now(timezone.utc).isoformat() + due_list = get_due_open_notifications(now_iso) + if not due_list: + return + + lectures_by_id = {str(lec["id"]): lec for lec in lectures} + + for due in due_list: + mark_open_notified(due["lecture_id"]) + + lecture = lectures_by_id.get(due["lecture_id"]) + if lecture is None: + print( + f"[신청시작 알림 스킵] 강연 {due['lecture_id']}({due.get('title')})을 목록에서 찾지 못함" + ) + continue + + await send_to_all_notify_channels( + lecture, + "강연 신청이 시작됐어요!", + make_open_embed(lecture), + ) + await asyncio.sleep(0.5) + + @tasks.loop(seconds=POLL_INTERVAL) async def poll_api() -> None: try: - lectures = fetch_open_lectures() + all_lectures = fetch_all_lectures() + + for lecture in all_lectures: + lecture_id = lecture["id"] + if claim_notification(lecture_id, "submitted", lecture["title"]): + await send_to_student_council(make_submission_embed(lecture)) + await asyncio.sleep(0.5) + + lectures = [ + lec + for lec in all_lectures + if lec["status"] in OPEN_STATUSES + and lec.get("approval_status") == "APPROVED" + ] enroll_map = fetch_enrollment_counts(lectures) for lecture in lectures: @@ -292,6 +395,9 @@ async def poll_api() -> None: "새 릴레이 스터디가 등록됐어요!", make_new_lecture_embed(lecture), ) + approved_at = lecture.get("approved_at") or datetime.now(timezone.utc) + open_at_iso = _compute_open_at_iso(approved_at) + schedule_open_notification(lecture_id, open_at_iso, lecture["title"]) await asyncio.sleep(0.5) if is_confirmed_lecture(lecture, enrolled_count) and claim_notification( @@ -304,6 +410,8 @@ async def poll_api() -> None: ) await asyncio.sleep(0.5) + await _process_due_open_notifications(lectures) + except ApiError as exc: print(f"[API 오류] {exc}") except Exception as exc: @@ -316,8 +424,18 @@ async def before_poll() -> None: init_state_store() try: - lectures = fetch_open_lectures() + all_lectures = fetch_all_lectures() + for lecture in all_lectures: + mark_notified(lecture["id"], "submitted", lecture["title"]) + + lectures = [ + lec + for lec in all_lectures + if lec["status"] in OPEN_STATUSES + and lec.get("approval_status") == "APPROVED" + ] enroll_map = fetch_enrollment_counts(lectures) + now_iso = datetime.now(timezone.utc).isoformat() for lecture in lectures: lecture_id = lecture["id"] @@ -327,6 +445,9 @@ async def before_poll() -> None: if lecture.get("status") == "OPEN": mark_notified(lecture_id, "new", lecture["title"]) + schedule_open_notification( + lecture_id, now_iso, lecture["title"], notified=1 + ) if is_confirmed_lecture(lecture, enrolled_count): mark_notified(lecture_id, "confirmed", lecture["title"]) diff --git a/state_store.py b/state_store.py index ee5ea9f..dbee040 100644 --- a/state_store.py +++ b/state_store.py @@ -2,7 +2,7 @@ import sqlite3 from contextlib import closing from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any, Dict, List, Optional from dotenv import load_dotenv @@ -20,6 +20,15 @@ ) """ +_CREATE_OPEN_SCHEDULE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS lecture_open_schedule ( + lecture_id TEXT PRIMARY KEY, + open_at TEXT NOT NULL, + title TEXT, + notified INTEGER NOT NULL DEFAULT 0 +) +""" + _INSERT_SQL = """ INSERT OR IGNORE INTO lecture_notifications (lecture_id, notification_type, sent_at, title) VALUES (?, ?, ?, ?) @@ -30,6 +39,20 @@ WHERE lecture_id = ? AND notification_type = ? """ +_INSERT_OPEN_SCHEDULE_SQL = """ +INSERT OR IGNORE INTO lecture_open_schedule (lecture_id, open_at, title, notified) +VALUES (?, ?, ?, ?) +""" + +_SELECT_DUE_OPEN_SQL = """ +SELECT lecture_id, open_at, title FROM lecture_open_schedule +WHERE notified = 0 AND open_at <= ? +""" + +_UPDATE_OPEN_NOTIFIED_SQL = """ +UPDATE lecture_open_schedule SET notified = 1 WHERE lecture_id = ? +""" + def _connect() -> sqlite3.Connection: return sqlite3.connect(STATE_DB_PATH) @@ -39,6 +62,7 @@ def init_state_store() -> None: with closing(_connect()) as conn: with conn: conn.execute(_CREATE_TABLE_SQL) + conn.execute(_CREATE_OPEN_SCHEDULE_TABLE_SQL) conn.execute("PRAGMA journal_mode=WAL;") @@ -65,3 +89,36 @@ def mark_notified( lecture_id: Any, notification_type: str, title: Optional[str] = None ) -> bool: return claim_notification(lecture_id, notification_type, title) + + +def schedule_open_notification( + lecture_id: Any, + open_at_iso: str, + title: Optional[str] = None, + notified: int = 0, +) -> None: + """신청 시작(오후 4시 20분) 알림을 예약한다. + + notified=1로 넣으면 '이미 처리된 것'으로 기록되어 실제 알림은 나가지 않는다. + (봇 재시작 시 기존 강연들에 대해 사용) + 이미 같은 lecture_id로 예약된 건이 있으면 무시된다. + """ + with closing(_connect()) as conn: + with conn: + conn.execute( + _INSERT_OPEN_SCHEDULE_SQL, + (str(lecture_id), open_at_iso, title, notified), + ) + + +def get_due_open_notifications(now_iso: str) -> List[Dict[str, Any]]: + """아직 알림을 안 보냈고, 예정 시각이 지난 강연 목록을 반환한다.""" + with closing(_connect()) as conn: + rows = conn.execute(_SELECT_DUE_OPEN_SQL, (now_iso,)).fetchall() + return [{"lecture_id": row[0], "open_at": row[1], "title": row[2]} for row in rows] + + +def mark_open_notified(lecture_id: Any) -> None: + with closing(_connect()) as conn: + with conn: + conn.execute(_UPDATE_OPEN_NOTIFIED_SQL, (str(lecture_id),))