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
24 changes: 21 additions & 3 deletions api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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]]:
Expand Down
133 changes: 127 additions & 6 deletions bot.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -62,13 +70,21 @@ 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"))

CONFIRMED_STATUSES = {"CONFIRMED", "CONFIRM"}
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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}명 이상 모여 개설 확정됐습니다!"
Expand All @@ -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:
Expand Down Expand Up @@ -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
)
Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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"]
Expand All @@ -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"])

Expand Down
59 changes: 58 additions & 1 deletion state_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 (?, ?, ?, ?)
Expand All @@ -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)
Expand All @@ -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;")


Expand All @@ -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),))
Loading