Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
42 changes: 42 additions & 0 deletions src/blog.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
64 changes: 64 additions & 0 deletions src/blog.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
163 changes: 163 additions & 0 deletions src/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
return { Authorization: `Bot ${token}` };
}

async function ensureOk(res: Response, what: string): Promise<void> {
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<string | null> {
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<Map<string, number>> {
const headers = authHeaders(token);
const firstByUser = new Map<string, number>();
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<string[]> {
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<void> {
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<void> {
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, "스레드 생성");
}
2 changes: 2 additions & 0 deletions src/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading