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
2 changes: 1 addition & 1 deletion .github/workflows/match.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 7 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)**:

Expand All @@ -80,7 +79,7 @@ bun run worker:register

- **스케줄**: 매주 월요일 UTC 00:00 (KST 09:00)
- **격주 실행**: 짝수 주에만 매칭 실행 (홀수 주는 skip)
- **수동 실행**: `workflow_dispatch`로 언제든지 수동 트리거 가능
- **수동 실행**: `workflow_dispatch`로 언제든지 수동 트리거 가능 (스케줄 무시, 즉시 매칭)
- **이력 관리**: 매칭 후 `data/history.json` 변경사항을 PR로 자동 생성

## Worker 배포
Expand Down
22 changes: 6 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
- `/coffee join` / `/coffee leave` 슬래시 명령어로 참여/탈퇴
- Discord Role 기반 참여자 자동 조회
- 중복 방지 알고리즘으로 매칭 생성 (최근 4회 이력 확인)
- Discord Webhook으로 매칭 결과 발표
- Discord Bot API로 매칭 결과 발표
- GitHub Actions를 통한 격주 자동 실행
- 매칭 이력 자동 PR 생성

Expand All @@ -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 생성

Expand All @@ -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을 등록해야 합니다.
Expand All @@ -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
Expand Down Expand Up @@ -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`)

## 매칭 알고리즘
Expand Down
10 changes: 9 additions & 1 deletion data/roles.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
29 changes: 17 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}: ✅ 매칭 완료!`);
}
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
46 changes: 26 additions & 20 deletions src/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[][] = [
[
Expand All @@ -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("↔");
Expand All @@ -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;
Expand All @@ -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 () => {
Expand All @@ -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!);
Expand All @@ -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!);
Expand Down
23 changes: 16 additions & 7 deletions src/webhook.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand All @@ -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에 매칭 결과 발표 완료");
Expand Down