From 5b1cce5d544d5952d23a955cfb1a5261f8d3d5d2 Mon Sep 17 00:00:00 2001 From: hikeong Date: Fri, 28 Aug 2026 08:24:59 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EA=B0=95=EC=97=B0=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=EC=9E=91=20=EC=95=8C=EB=A6=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++++-- state_store.py | 68 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 135 insertions(+), 9 deletions(-) diff --git a/bot.py b/bot.py index b2a42c1..0dd097e 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 @@ -15,7 +15,14 @@ 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() @@ -69,6 +76,11 @@ def _parse_channel_role_map(env_val: str) -> Dict[int, int]: EMBED_COLOR = 0xE8B84B FOOTER_TEXT = "GSM 릴스 봇" +# 신청 시작 시각 (한국 시간 기준 오후 4시 20분) +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 +142,15 @@ def _make_progress_bar(enrolled: int, capacity: int, width: int = 10) -> str: return "█" * filled + "░" * (width - filled) +def _compute_open_at_iso(now_utc: datetime) -> str: + """오늘(한국시간 기준) 오후 4시 20분을 UTC ISO 문자열로 계산한다.""" + now_kst = now_utc.astimezone(KST) + open_kst = now_kst.replace( + hour=OPEN_HOUR, minute=OPEN_MINUTE, second=0, microsecond=0 + ) + return open_kst.astimezone(timezone.utc).isoformat() + + def _build_base_embed( title: str, lecture: Dict[str, Any], description: Optional[str] = None ) -> discord.Embed: @@ -184,6 +205,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: @@ -272,6 +306,33 @@ async def send_confirmed_notification( ) +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: + # 먼저 notified 처리해서, 강연을 못 찾아도 다음 폴링에서 또 시도하지 않도록 한다. + 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: @@ -292,6 +353,8 @@ async def poll_api() -> None: "새 릴레이 스터디가 등록됐어요!", make_new_lecture_embed(lecture), ) + open_at_iso = _compute_open_at_iso(datetime.now(timezone.utc)) + 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 +367,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: @@ -318,6 +383,7 @@ async def before_poll() -> None: try: lectures = fetch_open_lectures() enroll_map = fetch_enrollment_counts(lectures) + now_iso = datetime.now(timezone.utc).isoformat() for lecture in lectures: lecture_id = lecture["id"] @@ -327,6 +393,10 @@ 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"]) @@ -479,3 +549,5 @@ async def on_app_command_error( if not DISCORD_TOKEN: raise RuntimeError("DISCORD_TOKEN이 .env에 설정되어 있지 않습니다.") bot.run(DISCORD_TOKEN) + + \ No newline at end of file diff --git a/state_store.py b/state_store.py index ee5ea9f..077f509 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;") @@ -48,9 +72,7 @@ def was_notified(lecture_id: Any, notification_type: str) -> bool: return row is not None -def claim_notification( - lecture_id: Any, notification_type: str, title: Optional[str] = None -) -> bool: +def claim_notification(lecture_id: Any, notification_type: str, title: Optional[str] = None) -> bool: now_iso = datetime.now(timezone.utc).isoformat() with closing(_connect()) as conn: with conn: @@ -61,7 +83,39 @@ def claim_notification( return cur.rowcount == 1 -def mark_notified( - lecture_id: Any, notification_type: str, title: Optional[str] = None -) -> bool: +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),)) + \ No newline at end of file From 3021840ddd9a20c157dcbf45312098cd2f1a9a9a Mon Sep 17 00:00:00 2001 From: hikeong Date: Fri, 28 Aug 2026 09:19:47 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=95=8C=EB=A6=BC=20=EC=8B=9C=EA=B0=81=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=EC=9D=84=20createdAt=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_client.py | 4 ++++ bot.py | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/api_client.py b/api_client.py index ff9016c..ef5ac21 100644 --- a/api_client.py +++ b/api_client.py @@ -160,6 +160,8 @@ 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")) + lecture_id = _first(raw, "id", "lectureId") url_id = _first(raw, "lectureId", "id") @@ -188,6 +190,7 @@ 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, } @@ -225,3 +228,4 @@ def fetch_enrollment_counts( } for lecture in target_lectures } + diff --git a/bot.py b/bot.py index 0dd097e..d9abd84 100644 --- a/bot.py +++ b/bot.py @@ -142,12 +142,18 @@ def _make_progress_bar(enrolled: int, capacity: int, width: int = 10) -> str: return "█" * filled + "░" * (width - filled) -def _compute_open_at_iso(now_utc: datetime) -> str: - """오늘(한국시간 기준) 오후 4시 20분을 UTC ISO 문자열로 계산한다.""" - now_kst = now_utc.astimezone(KST) - open_kst = now_kst.replace( +def _compute_open_at_iso(created_at_utc: datetime) -> str: + """강연 등록 시각(created_at) 기준으로 신청 시작 시각을 계산해 UTC ISO 문자열로 반환한다. + + 규칙: 등록 시각이 그날 오후 4시 20분(KST) 이전이면 그날 4시 20분에 시작하고, + 이미 지났으면 다음날 4시 20분으로 넘어간다. (rels.io.kr의 '신청 시작(자동)' 값과 동일한 규칙) + """ + created_kst = created_at_utc.astimezone(KST) + open_kst = created_kst.replace( hour=OPEN_HOUR, minute=OPEN_MINUTE, second=0, microsecond=0 ) + if created_kst >= open_kst: + open_kst += timedelta(days=1) return open_kst.astimezone(timezone.utc).isoformat() @@ -353,7 +359,8 @@ async def poll_api() -> None: "새 릴레이 스터디가 등록됐어요!", make_new_lecture_embed(lecture), ) - open_at_iso = _compute_open_at_iso(datetime.now(timezone.utc)) + created_at = lecture.get("created_at") or datetime.now(timezone.utc) + open_at_iso = _compute_open_at_iso(created_at) schedule_open_notification(lecture_id, open_at_iso, lecture["title"]) await asyncio.sleep(0.5) From 8edc67ef9d3e2c4d27375fde65c3b1d4f0bcb427 Mon Sep 17 00:00:00 2001 From: hikeong Date: Tue, 1 Sep 2026 21:01:16 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=EC=8B=A0=EC=B2=AD=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=EA=B0=81=20=EA=B3=84=EC=82=B0=EC=9D=84=20?= =?UTF-8?q?approvedAt=20=ED=95=84=EB=93=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_client.py | 1 - bot.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/api_client.py b/api_client.py index ef5ac21..90ab9bf 100644 --- a/api_client.py +++ b/api_client.py @@ -228,4 +228,3 @@ def fetch_enrollment_counts( } for lecture in target_lectures } - diff --git a/bot.py b/bot.py index d9abd84..72cd301 100644 --- a/bot.py +++ b/bot.py @@ -556,5 +556,3 @@ async def on_app_command_error( if not DISCORD_TOKEN: raise RuntimeError("DISCORD_TOKEN이 .env에 설정되어 있지 않습니다.") bot.run(DISCORD_TOKEN) - - \ No newline at end of file From 47b414ed449c2f85e27293755774de662691d308 Mon Sep 17 00:00:00 2001 From: hikeong Date: Wed, 2 Sep 2026 09:59:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=ED=95=99=EC=83=88=ED=9A=8C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9E=84=EB=B2=A0=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_client.py | 22 ++++++++++++-- bot.py | 80 +++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/api_client.py b/api_client.py index 90ab9bf..e5d9154 100644 --- a/api_client.py +++ b/api_client.py @@ -161,6 +161,11 @@ def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: 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") @@ -191,13 +196,23 @@ def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: "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]]: @@ -228,3 +243,4 @@ def fetch_enrollment_counts( } for lecture in target_lectures } + diff --git a/bot.py b/bot.py index 72cd301..05c8a05 100644 --- a/bot.py +++ b/bot.py @@ -10,7 +10,9 @@ from api_client import ( ApiError, + OPEN_STATUSES, fetch_active_lectures, + fetch_all_lectures, fetch_all_lectures_basic, fetch_enrollment_counts, fetch_open_lectures, @@ -69,6 +71,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")) @@ -76,7 +82,6 @@ def _parse_channel_role_map(env_val: str) -> Dict[int, int]: EMBED_COLOR = 0xE8B84B FOOTER_TEXT = "GSM 릴스 봇" -# 신청 시작 시각 (한국 시간 기준 오후 4시 20분) KST = timezone(timedelta(hours=9)) OPEN_HOUR = int(os.getenv("OPEN_HOUR", "16")) OPEN_MINUTE = int(os.getenv("OPEN_MINUTE", "20")) @@ -142,17 +147,12 @@ def _make_progress_bar(enrolled: int, capacity: int, width: int = 10) -> str: return "█" * filled + "░" * (width - filled) -def _compute_open_at_iso(created_at_utc: datetime) -> str: - """강연 등록 시각(created_at) 기준으로 신청 시작 시각을 계산해 UTC ISO 문자열로 반환한다. - - 규칙: 등록 시각이 그날 오후 4시 20분(KST) 이전이면 그날 4시 20분에 시작하고, - 이미 지났으면 다음날 4시 20분으로 넘어간다. (rels.io.kr의 '신청 시작(자동)' 값과 동일한 규칙) - """ - created_kst = created_at_utc.astimezone(KST) - open_kst = created_kst.replace( +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 created_kst >= open_kst: + if approved_kst >= open_kst: open_kst += timedelta(days=1) return open_kst.astimezone(timezone.utc).isoformat() @@ -196,6 +196,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}명 이상 모여 개설 확정됐습니다!" @@ -294,7 +307,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 ) @@ -312,6 +324,20 @@ 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) @@ -321,7 +347,6 @@ async def _process_due_open_notifications(lectures: List[Dict[str, Any]]) -> Non lectures_by_id = {str(lec["id"]): lec for lec in lectures} for due in due_list: - # 먼저 notified 처리해서, 강연을 못 찾아도 다음 폴링에서 또 시도하지 않도록 한다. mark_open_notified(due["lecture_id"]) lecture = lectures_by_id.get(due["lecture_id"]) @@ -342,7 +367,19 @@ async def _process_due_open_notifications(lectures: List[Dict[str, Any]]) -> Non @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: @@ -359,8 +396,8 @@ async def poll_api() -> None: "새 릴레이 스터디가 등록됐어요!", make_new_lecture_embed(lecture), ) - created_at = lecture.get("created_at") or datetime.now(timezone.utc) - open_at_iso = _compute_open_at_iso(created_at) + 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) @@ -388,7 +425,15 @@ 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() @@ -400,7 +445,6 @@ 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 ) @@ -555,4 +599,4 @@ async def on_app_command_error( if __name__ == "__main__": if not DISCORD_TOKEN: raise RuntimeError("DISCORD_TOKEN이 .env에 설정되어 있지 않습니다.") - bot.run(DISCORD_TOKEN) + bot.run(DISCORD_TOKEN) \ No newline at end of file From 4680c46162e63c24ed6800c43214aa52fa4b4e30 Mon Sep 17 00:00:00 2001 From: hikeong Date: Wed, 2 Sep 2026 10:02:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot.py b/bot.py index 05c8a05..3b8ec53 100644 --- a/bot.py +++ b/bot.py @@ -197,7 +197,6 @@ def make_new_lecture_embed(lecture: Dict[str, Any]) -> discord.Embed: def make_submission_embed(lecture: Dict[str, Any]) -> discord.Embed: - """학생회용: 강연 신청서가 새로 접수됐을 때 보내는 임베드.""" embed = _build_base_embed("📝새 강연 신청서가 접수됐어요!", lecture) if lecture.get("lecture_url"): embed.add_field( From c9a4ef90493f170c303b7598ed60404fa1453f24 Mon Sep 17 00:00:00 2001 From: hikeong Date: Wed, 2 Sep 2026 10:04:47 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EC=A4=84=20=EB=93=A4=EC=97=AC=EC=93=B0=EA=B8=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api_client.py | 1 - bot.py | 8 +++++--- state_store.py | 9 ++++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/api_client.py b/api_client.py index e5d9154..14418df 100644 --- a/api_client.py +++ b/api_client.py @@ -243,4 +243,3 @@ def fetch_enrollment_counts( } for lecture in target_lectures } - diff --git a/bot.py b/bot.py index 3b8ec53..364aa2a 100644 --- a/bot.py +++ b/bot.py @@ -377,7 +377,8 @@ async def poll_api() -> None: lectures = [ lec for lec in all_lectures - if lec["status"] in OPEN_STATUSES and lec.get("approval_status") == "APPROVED" + if lec["status"] in OPEN_STATUSES + and lec.get("approval_status") == "APPROVED" ] enroll_map = fetch_enrollment_counts(lectures) @@ -431,7 +432,8 @@ async def before_poll() -> None: lectures = [ lec for lec in all_lectures - if lec["status"] in OPEN_STATUSES and lec.get("approval_status") == "APPROVED" + 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() @@ -598,4 +600,4 @@ async def on_app_command_error( if __name__ == "__main__": if not DISCORD_TOKEN: raise RuntimeError("DISCORD_TOKEN이 .env에 설정되어 있지 않습니다.") - bot.run(DISCORD_TOKEN) \ No newline at end of file + bot.run(DISCORD_TOKEN) diff --git a/state_store.py b/state_store.py index 077f509..dbee040 100644 --- a/state_store.py +++ b/state_store.py @@ -72,7 +72,9 @@ def was_notified(lecture_id: Any, notification_type: str) -> bool: return row is not None -def claim_notification(lecture_id: Any, notification_type: str, title: Optional[str] = None) -> bool: +def claim_notification( + lecture_id: Any, notification_type: str, title: Optional[str] = None +) -> bool: now_iso = datetime.now(timezone.utc).isoformat() with closing(_connect()) as conn: with conn: @@ -83,7 +85,9 @@ def claim_notification(lecture_id: Any, notification_type: str, title: Optional[ return cur.rowcount == 1 -def mark_notified(lecture_id: Any, notification_type: str, title: Optional[str] = None) -> bool: +def mark_notified( + lecture_id: Any, notification_type: str, title: Optional[str] = None +) -> bool: return claim_notification(lecture_id, notification_type, title) @@ -118,4 +122,3 @@ def mark_open_notified(lecture_id: Any) -> None: with closing(_connect()) as conn: with conn: conn.execute(_UPDATE_OPEN_NOTIFIED_SQL, (str(lecture_id),)) - \ No newline at end of file From 30eb66c4a0d8698ed06fab42a58fb6ba558b1845 Mon Sep 17 00:00:00 2001 From: hikeong Date: Wed, 2 Sep 2026 10:08:19 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20fetch=5Fopen=5Flectures=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot.py b/bot.py index 364aa2a..7c21596 100644 --- a/bot.py +++ b/bot.py @@ -15,7 +15,6 @@ fetch_all_lectures, fetch_all_lectures_basic, fetch_enrollment_counts, - fetch_open_lectures, ) from state_store import ( claim_notification, @@ -197,6 +196,7 @@ def make_new_lecture_embed(lecture: Dict[str, Any]) -> discord.Embed: def make_submission_embed(lecture: Dict[str, Any]) -> discord.Embed: + """학생회용: 강연 신청서가 새로 접수됐을 때 보내는 임베드.""" embed = _build_base_embed("📝새 강연 신청서가 접수됐어요!", lecture) if lecture.get("lecture_url"): embed.add_field( @@ -324,7 +324,6 @@ 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: