diff --git a/.github/workflows/match.yml b/.github/workflows/match.yml index 4ed2342..061e5bb 100644 --- a/.github/workflows/match.yml +++ b/.github/workflows/match.yml @@ -25,8 +25,8 @@ jobs: - name: Run matching env: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} - DISCORD_WEBHOOK_URL_COFFEE: ${{ secrets.DISCORD_WEBHOOK_URL_COFFEE }} DISCORD_SERVER_ID: ${{ vars.DISCORD_SERVER_ID }} + FORCE_RUN: ${{ github.event_name == 'workflow_dispatch' && 'true' || '' }} run: bun run match - name: Format generated files diff --git a/AGENTS.md b/AGENTS.md index c9af875..665279c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,11 +38,12 @@ bun run worker:register ### 매칭 실행 흐름 (src/index.ts) -1. **참여자 조회** (`discord.ts`) - Discord API로 특정 Role을 가진 멤버 목록 가져오기 -2. **매칭 이력 로드** (`matcher.ts`) - `data/history.json`에서 과거 매칭 기록 로드 -3. **매칭 생성** (`matcher.ts`) - Fisher-Yates 셔플 + 중복 방지 알고리즘 -4. **이력 저장** (`matcher.ts`) - 새로운 매칭을 history.json에 추가 -5. **Discord 발표** (`webhook.ts`) - Webhook으로 매칭 결과 채널에 공지 +1. **스케줄 체크** (`schedule.ts`) - 역할별 스케줄에 따라 매칭 실행 여부 판단 (수동 트리거 시 건너뜀) +2. **참여자 조회** (`discord.ts`) - Discord API로 특정 Role을 가진 멤버 목록 가져오기 +3. **매칭 이력 로드** (`matcher.ts`) - `data/history.json`에서 과거 매칭 기록 로드 +4. **매칭 생성** (`matcher.ts`) - Fisher-Yates 셔플 + 중복 방지 알고리즘 +5. **이력 저장** (`matcher.ts`) - 새로운 매칭을 history.json에 추가 +6. **Discord 발표** (`webhook.ts`) - Bot API로 매칭 결과 채널에 공지 ### 슬래시 명령어 처리 흐름 (worker/src/index.ts) @@ -62,9 +63,7 @@ bun run worker:register **매칭 (GitHub Actions)**: - `DISCORD_BOT_TOKEN` - Discord Bot 토큰 (Secret) -- `DISCORD_WEBHOOK_URL` - 매칭 결과 발표용 Webhook URL (Secret) - `DISCORD_SERVER_ID` - 디스코드 서버 ID (Variable) -- `DISCORD_ROLE_ID` - 커피챗 참여자 Role ID (Variable) **Worker (Cloudflare)**: @@ -80,7 +79,7 @@ bun run worker:register - **스케줄**: 매주 월요일 UTC 00:00 (KST 09:00) - **격주 실행**: 짝수 주에만 매칭 실행 (홀수 주는 skip) -- **수동 실행**: `workflow_dispatch`로 언제든지 수동 트리거 가능 +- **수동 실행**: `workflow_dispatch`로 언제든지 수동 트리거 가능 (스케줄 무시, 즉시 매칭) - **이력 관리**: 매칭 후 `data/history.json` 변경사항을 PR로 자동 생성 ## Worker 배포 diff --git a/README.md b/README.md index 75e7462..c019f7c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ - `/coffee join` / `/coffee leave` 슬래시 명령어로 참여/탈퇴 - Discord Role 기반 참여자 자동 조회 - 중복 방지 알고리즘으로 매칭 생성 (최근 4회 이력 확인) -- Discord Webhook으로 매칭 결과 발표 +- Discord Bot API로 매칭 결과 발표 - GitHub Actions를 통한 격주 자동 실행 - 매칭 이력 자동 PR 생성 @@ -30,21 +30,19 @@ bun install | 환경변수 | 설명 | 타입 | |---------|------|------| | `DISCORD_BOT_TOKEN` | Discord Bot 토큰 | Secret | -| `DISCORD_WEBHOOK_URL` | Discord Webhook URL | Secret | | `DISCORD_SERVER_ID` | Discord 서버(Guild) ID | Variable | -| `DISCORD_ROLE_ID` | 커피챗 참여자 Role ID | Variable | + +> 매칭 결과를 발표할 채널 ID와 참여자 Role ID는 `data/roles.json`에서 관리합니다. ### GitHub Actions 설정 1. Repository Settings > Secrets and variables > Actions 2. **Secrets** 탭에서 추가: - `DISCORD_BOT_TOKEN` - - `DISCORD_WEBHOOK_URL` 3. **Variables** 탭에서 추가: - `DISCORD_SERVER_ID` - - `DISCORD_ROLE_ID` -## Discord 봇 & Webhook 설정 +## Discord 봇 설정 ### Bot 생성 @@ -59,16 +57,10 @@ bun install 2. **SERVER MEMBERS INTENT** 활성화 (필수) 3. OAuth2 > URL Generator에서 권한 설정: - Scopes: `bot`, `applications.commands` - - Bot Permissions: `Read Messages/View Channels`, `Manage Roles` + - Bot Permissions: `Read Messages/View Channels`, `Send Messages`, `Manage Roles` 4. 생성된 URL로 서버에 봇 초대 5. **중요**: 서버 설정 > 역할에서 봇 역할이 커피챗 Role보다 **위에** 위치해야 합니다 -### Webhook 생성 - -1. Discord 서버 > 채널 설정 > 연동 -2. "웹후크 만들기" 클릭 -3. Webhook URL 복사 → `DISCORD_WEBHOOK_URL`로 사용 - ### Interactions Endpoint URL 설정 슬래시 명령어(`/coffee join`, `/coffee leave`)를 사용하려면 Worker 배포 후 Endpoint URL을 등록해야 합니다. @@ -92,9 +84,7 @@ bun install ```bash # 환경변수 설정 후 export DISCORD_BOT_TOKEN="your-token" -export DISCORD_WEBHOOK_URL="your-webhook-url" export DISCORD_SERVER_ID="your-server-id" -export DISCORD_ROLE_ID="your-role-id" # 매칭 실행 bun run match @@ -122,7 +112,7 @@ gh workflow run match.yml 2. 매칭 이력 로드 (`data/history.json`) 3. 중복 방지 알고리즘으로 매칭 생성 4. `history.json`에 새 매칭 저장 -5. Discord Webhook으로 결과 발표 +5. Discord Bot API로 결과 발표 6. 자동 PR 생성 (`chore/update-match-history-YYYY-MM-DD`) ## 매칭 알고리즘 diff --git a/data/roles.json b/data/roles.json index deaf141..3af70e9 100644 --- a/data/roles.json +++ b/data/roles.json @@ -3,7 +3,15 @@ "name": "coffee", "displayName": "커피챗", "roleId": "1465029948887531533", - "webhookEnvKey": "DISCORD_WEBHOOK_URL_COFFEE", + "channelId": "1464985658966671474", + "schedule": "biweekly", + "groupSize": 3 + }, + { + "name": "coffee-ui", + "displayName": "커피챗 (달레UI)", + "roleId": "1477657822836686940", + "channelId": "1280152682044063837", "schedule": "biweekly", "groupSize": 2 } diff --git a/src/index.ts b/src/index.ts index 6a4e9c5..2b62b3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,29 +7,34 @@ import { announceMatches } from "./webhook.ts"; const roles = rolesConfig as RoleConfig[]; +function getEnvOrThrow(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`환경변수 ${key}가 설정되지 않았습니다.`); + } + return value; +} + async function main() { + const botToken = getEnvOrThrow("DISCORD_BOT_TOKEN"); + const forceRun = process.env.FORCE_RUN === "true"; + console.log("☕ 커피챗 매칭을 시작합니다...\n"); + if (forceRun) { + console.log("⚡ 수동 실행: 스케줄 체크를 건너뜁니다.\n"); + } for (const role of roles) { console.log(`--- [${role.displayName}] 역할 처리 중 ---`); - // 스케줄 체크 - if (!shouldRunToday(role.schedule)) { + // 스케줄 체크 (수동 실행 시 건너뜀) + if (!forceRun && !shouldRunToday(role.schedule)) { console.log( `${role.displayName}: 이번 주는 매칭 주가 아닙니다. 건너뜁니다.`, ); continue; } - // 웹훅 URL 확인 - const webhookUrl = process.env[role.webhookEnvKey]; - if (!webhookUrl) { - console.error( - `환경변수 ${role.webhookEnvKey}가 설정되지 않았습니다. ${role.displayName} 건너뜁니다.`, - ); - continue; - } - // 1. 참여자 목록 조회 const participants = await getParticipants(role.roleId); console.log( @@ -63,7 +68,7 @@ async function main() { await saveHistory(role.name, history, groups); // 5. Discord에 발표 - await announceMatches(webhookUrl, groups, role.displayName); + await announceMatches(role.channelId, botToken, groups, role.displayName); console.log(`${role.displayName}: ✅ 매칭 완료!`); } diff --git a/src/types.ts b/src/types.ts index 96c3bd4..3746f3c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,7 +30,7 @@ export interface RoleConfig { name: string; // slug (디렉토리명, autocomplete value) displayName: string; // Discord에 표시할 이름 roleId: string; // Discord 역할 ID - webhookEnvKey: string; // 웹훅 URL 환경변수 이름 + channelId: string; // 매칭 결과 발표 채널 ID schedule: MatchSchedule; // 매칭 주기 groupSize: number; // 그룹당 인원 수 (기본 2) } diff --git a/src/webhook.test.ts b/src/webhook.test.ts index 1f6d4db..276e850 100644 --- a/src/webhook.test.ts +++ b/src/webhook.test.ts @@ -2,14 +2,21 @@ import { describe, expect, mock, test } from "bun:test"; import type { Participant } from "./types.ts"; import { announceMatches } from "./webhook.ts"; -describe("announceMatches", () => { - test("올바른 형식으로 웹훅을 호출한다", async () => { - let capturedBody: string | undefined; +const TEST_CHANNEL_ID = "123456789"; +const TEST_BOT_TOKEN = "test-bot-token"; - globalThis.fetch = mock(async (_url: string, options: RequestInit) => { - capturedBody = options.body as string; - return new Response(null, { status: 200 }); - }) as unknown as typeof fetch; +describe("announceMatches", () => { + test("올바른 형식으로 Discord API를 호출한다", async () => { + let capturedUrl: string | undefined; + let capturedOptions: RequestInit | undefined; + + globalThis.fetch = mock( + async (url: string | URL | Request, options: RequestInit) => { + capturedUrl = url.toString(); + capturedOptions = options; + return new Response(null, { status: 200 }); + }, + ) as unknown as typeof fetch; const groups: Participant[][] = [ [ @@ -18,10 +25,13 @@ describe("announceMatches", () => { ], ]; - await announceMatches("https://discord.com/api/webhooks/test", groups); + await announceMatches(TEST_CHANNEL_ID, TEST_BOT_TOKEN, groups); - expect(capturedBody).toBeDefined(); - const parsed = JSON.parse(capturedBody!); + expect(capturedUrl).toContain(`/channels/${TEST_CHANNEL_ID}/messages`); + expect(capturedOptions!.headers).toMatchObject({ + Authorization: `Bot ${TEST_BOT_TOKEN}`, + }); + const parsed = JSON.parse(capturedOptions!.body as string); expect(parsed.content).toContain("<@123>"); expect(parsed.content).toContain("<@456>"); expect(parsed.content).toContain("↔"); @@ -43,14 +53,14 @@ describe("announceMatches", () => { ], ]; - await announceMatches("https://discord.com/api/webhooks/test", groups); + await announceMatches(TEST_CHANNEL_ID, TEST_BOT_TOKEN, groups); expect(capturedBody).toBeDefined(); const parsed = JSON.parse(capturedBody!); expect(parsed.content).toContain("(3인조)"); }); - test("웹훅 실패 시 에러를 던진다", async () => { + test("API 호출 실패 시 에러를 던진다", async () => { globalThis.fetch = mock(async () => { return new Response(null, { status: 500 }); }) as unknown as typeof fetch; @@ -63,8 +73,8 @@ describe("announceMatches", () => { ]; expect( - announceMatches("https://discord.com/api/webhooks/test", groups), - ).rejects.toThrow("Webhook 전송 실패: 500"); + announceMatches(TEST_CHANNEL_ID, TEST_BOT_TOKEN, groups), + ).rejects.toThrow("메시지 전송 실패: 500"); }); test("displayName이 있으면 메시지에 포함된다", async () => { @@ -82,11 +92,7 @@ describe("announceMatches", () => { ], ]; - await announceMatches( - "https://discord.com/api/webhooks/test", - groups, - "커피챗", - ); + await announceMatches(TEST_CHANNEL_ID, TEST_BOT_TOKEN, groups, "커피챗"); expect(capturedBody).toBeDefined(); const parsed = JSON.parse(capturedBody!); @@ -110,7 +116,7 @@ describe("announceMatches", () => { ], ]; - await announceMatches("https://discord.com/api/webhooks/test", groups); + await announceMatches(TEST_CHANNEL_ID, TEST_BOT_TOKEN, groups); expect(capturedBody).toBeDefined(); const parsed = JSON.parse(capturedBody!); diff --git a/src/webhook.ts b/src/webhook.ts index b41cbc5..8f1fae7 100644 --- a/src/webhook.ts +++ b/src/webhook.ts @@ -1,7 +1,10 @@ import type { Participant } from "./types.ts"; +const DISCORD_API_BASE = "https://discord.com/api/v10"; + export async function announceMatches( - webhookUrl: string, + channelId: string, + botToken: string, groups: Participant[][], displayName?: string, ): Promise { @@ -21,14 +24,20 @@ ${lines.join("\n")} 2주 안에 커피챗을 진행해주세요! ☕`; - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content }), - }); + const response = await fetch( + `${DISCORD_API_BASE}/channels/${channelId}/messages`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${botToken}`, + }, + body: JSON.stringify({ content }), + }, + ); if (!response.ok) { - throw new Error(`Webhook 전송 실패: ${response.status}`); + throw new Error(`메시지 전송 실패: ${response.status}`); } console.log("Discord에 매칭 결과 발표 완료");