diff --git a/Improvements.md b/Improvements.md deleted file mode 100644 index b24deb2..0000000 --- a/Improvements.md +++ /dev/null @@ -1,48 +0,0 @@ -## 스터디 채널 메시지 기반 자동 가입 처리 (CF Worker Cron) - -### 아이디어 -현재 `/verify` 슬래시 커맨드 외에, Discord의 특정 채널(예: `#study-join`)에 사용자가 메시지를 포스팅하면 동일한 로직(후원 체크 → GitHub 팀 초대 → Discord 역할 부여)이 자동으로 실행되도록 한다. - -실시간 처리가 아니어도 되며, 20분마다 또는 하루 1회 Cron으로 실행해도 충분. - -### 구현 방식: CF Worker Cron Trigger + Discord REST API 폴링 - -별도 서버 없이 기존 CF Worker에 `scheduled` 핸들러를 추가하는 방식. - -**흐름:** -1. Cron 실행 (예: 20분마다 `*/20 * * * *`) -2. Discord REST API로 `#study-join` 채널의 최근 메시지 조회 - - `GET /channels/{channelId}/messages` -3. Bot의 ✅ reaction이 없는 메시지만 필터링 (미처리 메시지 판별) -4. 메시지 내용 파싱 (github_username, team, role 추출) -5. 기존 `handleVerify` 핵심 로직 실행 -6. 결과를 해당 메시지에 Reply로 전송 -7. ✅ reaction 추가 (처리 완료 표시, 중복 처리 방지) - -### 필요한 작업 - -- [ ] `wrangler.toml`에 cron 트리거 추가 - ```toml - [triggers] - crons = ["*/20 * * * *"] - ``` -- [ ] `index.ts`에 `scheduled` 핸들러 추가 -- [ ] `handleVerify` 핵심 로직을 별도 함수로 분리 (slash command, cron 양쪽에서 재사용) -- [ ] `handleChannelJoin` 함수 구현 (메시지 조회 → 파싱 → 처리 → reaction 추가) -- [ ] `discord.ts`에 채널 메시지 조회, Reply 전송, Reaction 추가 함수 추가 -- [ ] `Env` 타입에 `STUDY_JOIN_CHANNEL_ID` 추가 -- [ ] `.dev.vars.example`에 `STUDY_JOIN_CHANNEL_ID` 추가 -- [ ] 채널 메시지 포맷 결정 (사용자가 어떤 형식으로 작성할지 정의 필요) - -### 채널 메시지 포맷 (미정, 논의 필요) -사용자가 `#study-join` 채널에 올릴 메시지 형식을 정해야 함. 예시: -``` -github: torvalds -team: algo -role: member -``` - -### 트레이드오프 -- 응답 지연 최대 20분 (허용 가능) -- 추가 서버 불필요, 기존 CF Worker만 사용 -- Reaction 방식으로 별도 DB/KV 없이 처리 여부 관리 가능 diff --git a/README.md b/README.md index 14eb781..d9aeb9f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,11 @@ Discord slash command `/verify ` → GitHub 후원 확인 → G ## 개요 -이 프로젝트는 Discord 서버에서 `/verify ` 명령을 통해 사용자의 GitHub 후원 여부를 확인하고, 후원자인 경우 자동으로 GitHub 조직 팀에 초대하고 Discord 역할(Role)을 부여하는 Cloudflare Worker 기반 봇입니다. +DaleStudy 커뮤니티 운영을 자동화하는 Cloudflare Worker 기반 Discord 봇입니다. 하나의 Worker가 세 가지 작업을 처리합니다. + +1. **`/verify` 슬래시 명령**: 사용자의 GitHub 후원 여부를 확인해, 후원자인 경우 GitHub 조직 팀에 초대하고 Discord 역할(Role)을 부여합니다. +2. **리트코드 스터디 가입 처리** (cron, 20분 주기): 스터디 신청 포럼에 올라온 글을 폴링해 위 검증 로직을 자동 적용합니다. +3. **블로그 발행 체크** (cron, 매주 월요일 09:00 KST): `blog` 역할 대상자가 이번 주 블로그를 발행(주간 스레드에 글 작성)했는지 확인해 리포트를 올리고 다음 주 스레드를 만듭니다. ## 아키텍처 @@ -30,6 +34,14 @@ GitHub API (Sponsors) │ └─ 후원 O ──► Discord REST API (역할 부여) ───┘ ``` +위 그림은 `/verify` 인터랙션(`fetch` 핸들러) 흐름입니다. 이와 별개로, 봇은 **Cron Trigger로 두 가지 작업**을 `scheduled` 핸들러에서 주기적으로 실행하며 `event.cron` 값으로 분기합니다. + +``` +Cloudflare Cron ──► Worker.scheduled(event.cron) + */20 * * * * ──► handleLeetCodeSignUp (신청 포럼 폴링 → 후원 검증 재사용) + 0 0 * * MON ──► handleBlogPublishCheck (blog 역할 발행 체크 → 리포트 + 새 스레드) +``` + ## 기술 스택 | Layer | Technology | @@ -49,6 +61,7 @@ community-manager/ │ ├── index.ts # Cloudflare Worker entry point │ ├── discord.ts # Discord 서명 검증, API 호출 │ ├── github.ts # GitHub GraphQL/REST API 호출 +│ ├── blog.ts # 블로그 발행 체크 로직 (KST 주간 경계·발행 상태 분류) │ └── types.ts # 공통 타입 정의 ├── scripts/ │ └── register.ts # Discord slash command 등록 스크립트 @@ -82,6 +95,8 @@ community-manager/ | ------------------ | ----------------- | | `GITHUB_ORG` | GitHub 조직 이름 | | `ROLE_TEAM_CONFIG` | 역할-팀 매핑 (JSON, 슬래시 선택지) | +| `BLOG_STUDY_CHANNEL_ID` | 블로그 발행 체크: 주간 스레드 부모 채널 ID | +| `BLOG_STUDY_ROLE_ID` | 블로그 발행 체크: 대상 `blog` 역할 ID | ### 로컬 개발 (.dev.vars) @@ -142,6 +157,7 @@ wrangler deploy - **GitHub App 권한**: 후원 확인(GraphQL)과 팀 초대(REST) 모두 처리. Organization Members: Read & Write 권한 필요 - **Bot 역할 위치**: Discord에서 Bot이 부여할 역할보다 높은 위치에 있어야 함 - **중복 실행 방지**: PUT 방식으로 멱등성 보장 +- **블로그 발행 체크**: 역할 보유자 열거(`GET /guilds/{id}/members`)에 GUILD_MEMBERS(privileged) 인텐트 필요. 주간 cron은 매주 1회 단일 실행이라 동시성 경합 없음 ## 주요 동작 상세 @@ -172,6 +188,28 @@ wrangler deploy | 신규 초대 (org 멤버) | `✅ GitHub 팀 초대가 완료되었습니다.` | | 신규 초대 (외부 유저) | `✅ 팀 초대 메일이 발송되었습니다.` | +### 리트코드 스터디 가입 자동 처리 (cron) + +20분 주기(`*/20 * * * *`)로 스터디 신청 포럼(`STUDY_JOIN_CHANNEL_ID`)을 폴링합니다. 아직 처리되지 않은(리액션 없는) 신청 글의 제목/본문에서 `(github_username, team)`을 파싱해 위 후원 검증 로직을 적용하고, 결과를 답글 + ✅/❌ 리액션으로 남깁니다. + +### 블로그 발행 체크 (주간 cron) + +매주 월요일 09:00 KST(`0 0 * * MON`)에 실행되어, `blog` 역할 대상자가 이번 주 블로그를 발행(주간 스레드에 글 작성)했는지 확인합니다. + +- 대상 채널(`BLOG_STUDY_CHANNEL_ID`)에서 **가장 최근에 만들어진 스레드**를 찾습니다. +- `blog` 역할 보유자(봇 제외)를 열거하고, 각자가 그 스레드에 남긴 **첫 메시지 시각**으로 분류합니다. + +| 상태 | 기준 (KST) | 처리 | +|---|---|---| +| 정상 발행 | 이번 주 월 00:00 이전 작성 | (조용히 통과) | +| 지각 | 이번 주 월 00:00 ~ 09:00 첫 작성 | 리포트에 지각 표기 | +| 경고 | 미작성 | 리포트에 경고 표기 | + +- 판정 결과를 채널에 리포트로 게시하고, **다음 주에 쓸 새 스레드**를 생성합니다. +- 첫 실행 시엔 확인할 스레드가 없어 스레드 생성만 하고(부트스트랩), 다음 주부터 직전 스레드를 검사합니다. + +> 주간 판정 창(지난 월 09:00 ~ 이번 월 09:00)과 분류 로직은 [`src/blog.ts`](src/blog.ts)에 있고, 경계는 단위 테스트([`src/blog.test.ts`](src/blog.test.ts))로 검증합니다. + ## 라이선스 이 프로젝트는 MIT 라이선스를 따릅니다. diff --git a/src/blog.test.ts b/src/blog.test.ts new file mode 100644 index 0000000..a5aefec --- /dev/null +++ b/src/blog.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { buildThreadName, classify, computeWeekWindow } from "./blog.js"; + +// 2026-06-29 09:00 KST = 2026-06-29 00:00 UTC (cron "0 0 * * MON" 발화 시각) +const REF = Date.parse("2026-06-29T00:00:00Z"); +const w = computeWeekWindow(REF); + +describe("computeWeekWindow", () => { + it("이번주 월 09:00 KST = 2026-06-29 00:00 UTC", () => { + expect(new Date(w.thisMonday9Utc).toISOString()).toBe("2026-06-29T00:00:00.000Z"); + }); + it("이번주 월 00:00 KST = 2026-06-28 15:00 UTC", () => { + expect(new Date(w.thisMondayMidnightUtc).toISOString()).toBe("2026-06-28T15:00:00.000Z"); + }); + it("지난주 월 09:00 KST = 2026-06-22 00:00 UTC", () => { + expect(new Date(w.lastMonday9Utc).toISOString()).toBe("2026-06-22T00:00:00.000Z"); + }); +}); + +describe("classify", () => { + it("미작성 → warn(경고)", () => { + expect(classify(undefined, w)).toBe("warn"); + }); + it("이번주 월 00:00(KST) 이전 작성 → normal(정상)", () => { + // 2026-06-28 22:00 KST + expect(classify(Date.parse("2026-06-28T13:00:00Z"), w)).toBe("normal"); + }); + it("이번주 월 00:00~09:00(KST) 사이 첫 작성 → late(지각)", () => { + // 2026-06-29 05:00 KST — 라이브로는 재현 못 했던 경로 + expect(classify(Date.parse("2026-06-28T20:00:00Z"), w)).toBe("late"); + }); + it("이번주 월 09:00(KST) 이후 작성 → warn(경고)", () => { + // 2026-06-29 10:00 KST + expect(classify(Date.parse("2026-06-29T01:00:00Z"), w)).toBe("warn"); + }); +}); + +describe("buildThreadName", () => { + it("블로그 06/29 - 07/05", () => { + expect(buildThreadName(REF)).toBe("블로그 06/29 - 07/05"); + }); +}); diff --git a/src/blog.ts b/src/blog.ts new file mode 100644 index 0000000..038c1ea --- /dev/null +++ b/src/blog.ts @@ -0,0 +1,64 @@ +/** 한국 시간대(KST, UTC+9) 오프셋 (ms) */ +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface WeekWindow { + /** 지난주 월요일 09:00(KST) — UTC ms */ + lastMonday9Utc: number; + /** 이번주 월요일 00:00(KST) — UTC ms */ + thisMondayMidnightUtc: number; + /** 이번주 월요일 09:00(KST) — UTC ms */ + thisMonday9Utc: number; +} + +/** + * 기준 시각(보통 cron이 발화한 "월요일 09:00 KST")을 바탕으로 + * 블로그 발행 판정에 쓰는 KST 주간 경계를 계산한다. + * + * blog-study Rust 원본의 this_week_monday_midnight_kst + 관련 계산을 옮긴 것. + */ +export function computeWeekWindow(referenceMs: number): WeekWindow { + // +9h 이동 후 getUTC* 를 쓰면 KST 벽시계 값을 얻는다. + const kst = new Date(referenceMs + KST_OFFSET_MS); + const dow = kst.getUTCDay(); // 0=일 .. 6=토 (KST 기준) + const daysFromMonday = (dow + 6) % 7; // 월=0 .. 일=6 + + // 이번 주 월요일 00:00 (shifted 공간의 자정) + const mondayMidnightShifted = + Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate()) - + daysFromMonday * DAY_MS; + + const thisMondayMidnightUtc = mondayMidnightShifted - KST_OFFSET_MS; + const thisMonday9Utc = thisMondayMidnightUtc + KST_OFFSET_MS; // 월 09:00 KST + const lastMonday9Utc = thisMonday9Utc - 7 * DAY_MS; + + return { lastMonday9Utc, thisMondayMidnightUtc, thisMonday9Utc }; +} + +export type BlogStatus = "normal" | "late" | "warn"; + +/** + * 한 대상자의 "첫 메시지 시각"(없으면 undefined)으로 블로그 발행 상태를 판정한다. + * - normal: 이번주 월 00:00(KST) 이전에 작성 (정상 발행) + * - late: 이번주 월 00:00~09:00(KST) 사이 첫 작성 (지각) + * - warn: 미작성 또는 그 외 (경고) + */ +export function classify(firstMessageMs: number | undefined, w: WeekWindow): BlogStatus { + if (firstMessageMs === undefined) return "warn"; + if (firstMessageMs < w.thisMondayMidnightUtc) return "normal"; + if (firstMessageMs >= w.thisMondayMidnightUtc && firstMessageMs <= w.thisMonday9Utc) return "late"; + return "warn"; +} + +/** 스레드 제목용 "블로그 MM/DD - MM/DD" (KST 기준, 기준 주 월~일) */ +export function buildThreadName(referenceMs: number): string { + const start = new Date(referenceMs + KST_OFFSET_MS); + const end = new Date(start.getTime() + 6 * DAY_MS); + return `블로그 ${fmtMonthDay(start)} - ${fmtMonthDay(end)}`; +} + +function fmtMonthDay(d: Date): string { + const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${mm}/${dd}`; +} diff --git a/src/discord.ts b/src/discord.ts index f6cb862..801b8ea 100644 --- a/src/discord.ts +++ b/src/discord.ts @@ -153,3 +153,166 @@ function hexToBytes(hex: string): Uint8Array { } return bytes; } + +// ── 블로그 발행 체크(blog-study) 헬퍼 ────────────────────────────── +// 게이트웨이 없이 Discord REST v10 fetch 만으로 동작한다. +const API = "https://discord.com/api/v10"; + +function authHeaders(token: string): Record { + return { Authorization: `Bot ${token}` }; +} + +async function ensureOk(res: Response, what: string): Promise { + if (!res.ok && res.status !== 204) { + let body: unknown; + try { + body = await res.json(); + } catch { + body = await res.text(); + } + throw new Error(`${what} 실패 (status=${res.status}): ${JSON.stringify(body)}`); + } +} + +// NEWS(10) / PUBLIC(11) / PRIVATE(12) 스레드 +const THREAD_TYPES = new Set([10, 11, 12]); + +/** + * 부모 채널 아래 스레드 중 가장 최근에 생성된(스레드 ID 최대) 것의 ID. + * 활성 스레드(길드) + 보관된 공개 스레드(채널)를 모두 본다. + * 스레드 ID는 snowflake(64bit)라 BigInt로 비교한다. + */ +export async function newestThreadUnderParent( + guildId: string, + channelId: string, + token: string, +): Promise { + const headers = authHeaders(token); + let best: bigint | null = null; + const consider = (id: string) => { + const v = BigInt(id); + if (best === null || v > best) best = v; + }; + + // 활성 스레드 + const activeRes = await fetch(`${API}/guilds/${guildId}/threads/active`, { headers }); + await ensureOk(activeRes, "활성 스레드 조회"); + const active = (await activeRes.json()) as any; + for (const t of active.threads ?? []) { + if (t.parent_id === channelId && THREAD_TYPES.has(t.type)) consider(t.id); + } + + // 보관된 공개 스레드 (archive_timestamp 기준 페이지네이션) + let before: string | undefined; + while (true) { + const url = new URL(`${API}/channels/${channelId}/threads/archived/public`); + url.searchParams.set("limit", "100"); + if (before) url.searchParams.set("before", before); + const res = await fetch(url, { headers }); + await ensureOk(res, "보관 스레드 조회"); + const page = (await res.json()) as any; + const threads: any[] = page.threads ?? []; + if (threads.length === 0) break; + for (const t of threads) if (THREAD_TYPES.has(t.type)) consider(t.id); + if (!page.has_more) break; + before = threads[threads.length - 1]?.thread_metadata?.archive_timestamp; + if (!before) break; + } + + return best === null ? null : best.toString(); +} + +/** + * 스레드 메시지를 [lastMonday9, thisMonday9] 창에서 읽어 + * 각 사람(비봇)의 "첫(최소) 메시지 시각(ms)"을 수집한다. + * Discord 메시지 목록은 최신순이라, 창보다 과거에 도달하면 중단한다. + */ +export async function collectFirstMessageTimes( + threadId: string, + token: string, + lastMonday9Ms: number, + thisMonday9Ms: number, +): Promise> { + const headers = authHeaders(token); + const firstByUser = new Map(); + let before: string | undefined; + + outer: while (true) { + const url = new URL(`${API}/channels/${threadId}/messages`); + url.searchParams.set("limit", "100"); + if (before) url.searchParams.set("before", before); + const res = await fetch(url, { headers }); + await ensureOk(res, "스레드 메시지 조회"); + const messages = (await res.json()) as any[]; + if (messages.length === 0) break; + + for (const msg of messages) { + const ts = Date.parse(msg.timestamp); + if (ts < lastMonday9Ms) break outer; // 창보다 과거 → 종료 + if (ts > thisMonday9Ms) continue; // 창보다 미래 → 무시 + if (msg.author?.bot) continue; + const uid = msg.author.id as string; + const prev = firstByUser.get(uid); + if (prev === undefined || ts < prev) firstByUser.set(uid, ts); + } + + before = messages[messages.length - 1].id; // 이 페이지에서 가장 오래된 메시지 + } + + return firstByUser; +} + +/** + * 특정 역할을 가진 사람(비봇) ID 목록 — 페이지네이션. + * ⚠️ GET /guilds/{id}/members 는 GUILD_MEMBERS(privileged) 인텐트가 켜져 있어야 동작한다. + */ +export async function listRoleMembers( + guildId: string, + roleId: string, + token: string, +): Promise { + const headers = authHeaders(token); + const ids: string[] = []; + let after = "0"; + + while (true) { + const url = new URL(`${API}/guilds/${guildId}/members`); + url.searchParams.set("limit", "1000"); + url.searchParams.set("after", after); + const res = await fetch(url, { headers }); + await ensureOk(res, "길드 멤버 조회"); + const members = (await res.json()) as any[]; + if (members.length === 0) break; + for (const m of members) { + if (!m.user?.bot && (m.roles ?? []).includes(roleId)) ids.push(m.user.id); + } + after = members[members.length - 1].user.id; + if (members.length < 1000) break; + } + + return ids; +} + +/** 채널에 텍스트 메시지 게시 (멘션 핑 허용) */ +export async function postMessage(channelId: string, content: string, token: string): Promise { + const res = await fetch(`${API}/channels/${channelId}/messages`, { + method: "POST", + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ content, allowed_mentions: { parse: ["users"] } }), + }); + await ensureOk(res, "메시지 전송"); +} + +/** 채널에 공개 스레드 생성 (메시지 없이, 7일 보관) */ +export async function createPublicThread( + channelId: string, + name: string, + token: string, +): Promise { + const res = await fetch(`${API}/channels/${channelId}/threads`, { + method: "POST", + headers: { ...authHeaders(token), "Content-Type": "application/json" }, + body: JSON.stringify({ name, type: 11, auto_archive_duration: 10080 }), + }); + await ensureOk(res, "스레드 생성"); +} diff --git a/src/github.test.ts b/src/github.test.ts index a3afff4..cbd1b76 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -14,6 +14,8 @@ const mockEnv: Env = { ROLE_TEAM_CONFIG: "[]", GH_PAT: "test-pat", STUDY_JOIN_CHANNEL_ID: "test-channel-id", + BLOG_STUDY_CHANNEL_ID: "test-blog-channel-id", + BLOG_STUDY_ROLE_ID: "test-blog-role-id", }; function makeSponsorshipResponse( diff --git a/src/index.ts b/src/index.ts index 0774b15..f0ef770 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,14 @@ import type { Env, RoleTeamConfig } from "./types.js"; import { getInstallationToken, checkSponsorship, getTeamCreatedAt, inviteToTeam, getTeamMembership } from "./github.js"; import { verifySignature, assignRole, getForumPosts, replyToMessage, addReaction } from "./discord.js"; +import { buildThreadName, classify, computeWeekWindow } from "./blog.js"; +import { + collectFirstMessageTimes, + createPublicThread, + listRoleMembers, + newestThreadUnderParent, + postMessage, +} from "./discord.js"; export default { async fetch( @@ -41,8 +49,13 @@ export default { return new Response("Unknown interaction type", { status: 400 }); }, - async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { - ctx.waitUntil(handleChannelJoin(env)); + async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + // cron 패턴으로 분기: 블로그 발행 체크(월 09:00 KST = 월 00:00 UTC) vs 20분 주기 가입 처리 + if (event.cron === "0 0 * * MON") { + ctx.waitUntil(handleBlogPublishCheck(env, event.scheduledTime)); + } else { + ctx.waitUntil(handleLeetCodeSignUp(env)); + } }, }; @@ -149,7 +162,7 @@ function parseJoinMessage(content: string): ParsedJoinMessage | null { }; } -async function handleChannelJoin(env: Env): Promise { +async function handleLeetCodeSignUp(env: Env): Promise { const posts = await getForumPosts(env.DISCORD_GUILD_ID, env.STUDY_JOIN_CHANNEL_ID, env.DISCORD_TOKEN); const unprocessed = posts.filter((msg) => { @@ -203,3 +216,77 @@ async function handleChannelJoin(env: Env): Promise { } } } + +// ── 블로그 발행 체크(blog-study) 처리 ────────────────────────────── +const ROLE_LABEL = "blog"; // 리포트 표시용 역할명 + +function mentions(ids: string[]): string { + return ids.map((id) => `<@${id}>`).join(" "); +} + +/** blog-study 원본 check_inactive_users 의 안내 문구. */ +function buildReport(warn: string[], late: string[]): string { + if (warn.length === 0 && late.length === 0) { + return `지난주 월요일 09:00부터 이번주 월요일 09:00까지 **${ROLE_LABEL}** 역할 대상자 모두 참여해서, 경고나 지각 대상자가 없습니다!`; + } + + const parts: string[] = []; + if (warn.length > 0) { + parts.push( + `지난주 월요일 09:00부터 이번주 월요일 09:00까지, **${ROLE_LABEL}** 역할 대상자 중 이 채널에서 가장 최근에 만들어진 스레드에 메시지를 남기지 않아 **경고 1회**를 받은 사람들:\n${mentions(warn)}`, + ); + } + if (late.length > 0) { + parts.push( + `이번주 월요일 00:00~09:00 사이에, **${ROLE_LABEL}** 역할 대상자 중 위 스레드에 처음으로 메시지를 남겨 **지각 1회**를 받은 사람들:\n${mentions(late)}`, + ); + } + return parts.join("\n\n"); +} + +/** + * 매주 월요일 09:00(KST) 실행. 가장 최근 스레드 기준으로 blog 역할 대상자의 + * 블로그 발행/지각을 판정해 리포트를 올리고, 이번 주에 쓸 새 스레드를 만든다. + */ +async function handleBlogPublishCheck(env: Env, referenceMs: number): Promise { + const token = env.DISCORD_TOKEN; + const guildId = env.DISCORD_GUILD_ID; + const channelId = env.BLOG_STUDY_CHANNEL_ID; + const roleId = env.BLOG_STUDY_ROLE_ID; + + const w = computeWeekWindow(referenceMs); + console.log( + `[blog] reference=${new Date(referenceMs).toISOString()} ` + + `window=[${new Date(w.lastMonday9Utc).toISOString()} .. ${new Date(w.thisMonday9Utc).toISOString()}]`, + ); + + const threadId = await newestThreadUnderParent(guildId, channelId, token); + if (!threadId) { + console.warn(`[blog] 부모 채널 ${channelId} 에 확인할 스레드가 없어 블로그 발행 체크를 건너뜁니다.`); + } else { + const firstTimes = await collectFirstMessageTimes(threadId, token, w.lastMonday9Utc, w.thisMonday9Utc); + const memberIds = await listRoleMembers(guildId, roleId, token); + + const warn: string[] = []; + const late: string[] = []; + for (const id of memberIds) { + switch (classify(firstTimes.get(id), w)) { + case "late": + late.push(id); + break; + case "warn": + warn.push(id); + break; + // "normal" → 정상 참여, 아무것도 하지 않음 + } + } + + await postMessage(channelId, buildReport(warn, late), token); + console.log(`[blog] report 게시 완료 warn=${warn.length} late=${late.length}`); + } + + // 이번 주에 쓸 새 스레드 생성 (스레드 유무와 무관하게 항상 생성) + const name = buildThreadName(referenceMs); + await createPublicThread(channelId, name, token); + console.log(`[blog] 스레드 생성 완료 "${name}"`); +} diff --git a/src/types.ts b/src/types.ts index 944cc98..737461c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,4 +17,8 @@ export interface Env { GH_PAT: string; DISCORD_APPLICATION_ID: string; STUDY_JOIN_CHANNEL_ID: string; + /** 블로그 발행 체크: 스레드를 만들 부모 채널 ID */ + BLOG_STUDY_CHANNEL_ID: string; + /** 블로그 발행 체크: 대상 'blog' 역할 ID */ + BLOG_STUDY_ROLE_ID: string; } diff --git a/wrangler.jsonc b/wrangler.jsonc index bac31b1..8123db4 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -7,6 +7,8 @@ "vars": { "GITHUB_ORG": "DaleStudy", "ROLE_TEAM_CONFIG": "[{\"value\":\"leetcode07\",\"label\":\"leetcode07\",\"discordRoleId\":\"1474508579523268701\",\"teamSlug\":\"leetcode07\"},{\"value\":\"leetcode08\",\"label\":\"leetcode08\",\"discordRoleId\":\"1513390176200818769\",\"teamSlug\":\"leetcode08\"}]", + "BLOG_STUDY_CHANNEL_ID": "1513687699729416232", + "BLOG_STUDY_ROLE_ID": "1520796867355611247", }, "kv_namespaces": [], "durable_objects": { @@ -20,7 +22,8 @@ "consumers": [], }, "triggers": { - "crons": ["*/20 * * * *"], + // "*/20 * * * *": 가입 처리 / "0 0 * * MON"(월 00:00 UTC = 09:00 KST): 블로그 발행 체크 + "crons": ["*/20 * * * *", "0 0 * * MON"], }, "routes": [], "no_bundle": false,