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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Type check
run: bun run typecheck

- name: Lint & Format check
run: bun run lint

- name: Run tests
run: bun test
57 changes: 57 additions & 0 deletions .github/workflows/match.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Coffee Chat Matching

on:
schedule:
# 매주 월요일 UTC 00:00 (KST 09:00)
- cron: "0 0 * * 1"
workflow_dispatch: # 수동 실행 가능

jobs:
match:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- uses: actions/checkout@v6

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install

- name: Check if this is a matching week
id: check-week
run: |
WEEK_NUM=$(date +%V)
if [ $((WEEK_NUM % 2)) -eq 0 ]; then
echo "skip=false" >> $GITHUB_OUTPUT
else
echo "skip=true" >> $GITHUB_OUTPUT
fi

- name: Run matching
if: steps.check-week.outputs.skip == 'false' || github.event_name == 'workflow_dispatch'
env:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
DISCORD_SERVER_ID: ${{ vars.DISCORD_SERVER_ID }}
DISCORD_ROLE_ID: ${{ vars.DISCORD_ROLE_ID }}
Comment thread
sounmind marked this conversation as resolved.
run: bun run match

- name: Create Pull Request
if: steps.check-week.outputs.skip == 'false' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
BRANCH_NAME="chore/update-match-history-$(date +%Y-%m-%d)"
git checkout -b "$BRANCH_NAME"
git add data/history.json
git diff --staged --quiet && exit 0
git commit -m "chore: update match history"
git push -u origin "$BRANCH_NAME"
gh pr create --fill --body "자동 생성된 커피챗 매칭 히스토리 업데이트입니다."
22 changes: 22 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Dependencies
node_modules/

# Build output
dist/

# Environment variables
.env
.env.local

# OS files
.DS_Store

# IDE
.idea/
.vscode/

# Logs
*.log
npm-debug.log*

CLAUDE.md
82 changes: 82 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

디스코드 서버 멤버들을 자동으로 매칭하여 커피챗(1:1 대화)을 연결해주는 봇입니다. GitHub Actions로 2주마다 자동 실행되며, Discord Role 기반으로 참여자를 관리합니다.

## Development Commands

```bash
# 매칭 실행 (로컬 테스트)
bun run match

# 테스트 실행
bun test

# 타입 체크
bun run typecheck

# Lint 체크
bun run lint

# 코드 포맷팅
bun run format
```

## Architecture

### 실행 흐름 (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으로 매칭 결과 채널에 공지

### 핵심 알고리즘 (matcher.ts)

- **중복 방지**: 최근 4회 매칭 이력과 비교하여 같은 조합 회피 (최대 100번 재시도)
- **홀수 처리**: 참여자가 홀수일 경우 마지막 조를 3인 1조로 구성
- **데이터 구조**: `data/history.json`에 날짜별 매칭 기록 저장

### 환경변수

**Secrets** (GitHub Secrets에 저장):

- `DISCORD_BOT_TOKEN` - Discord Bot 토큰 (Role 멤버 조회용)
- `DISCORD_WEBHOOK_URL` - 매칭 결과 발표용 Webhook URL

**Variables** (GitHub Variables에 저장):

- `DISCORD_SERVER_ID` - 디스코드 서버 ID
- `DISCORD_ROLE_ID` - 커피챗 참여자 Role ID

### GitHub Actions 자동화

`.github/workflows/match.yml`:

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

## Code Style

- **Runtime**: Bun (TypeScript 네이티브 지원)
- **Formatter**: Biome (tab indent, recommended rules)
- **Testing**: Bun test (`*.test.ts` 파일)
- **Import**: ESM (`type: "module"`)

## Testing

테스트 파일은 `*.test.ts` 형식으로 작성하며, Bun의 test runner를 사용합니다.

```typescript
// expect().toBeDefined() 후 non-null assertion 사용 패턴
expect(capturedBody).toBeDefined();
const parsed = JSON.parse(capturedBody!); // OK in tests
```

이 패턴 때문에 `biome.json`에서 `noNonNullAssertion` 규칙이 비활성화되어 있습니다.
16 changes: 16 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"formatter": {},
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
92 changes: 92 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions data/history.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"matches": []
}
21 changes: 21 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "coffee",
"version": "1.0.0",
"description": "디스코드 커피챗 매칭 봇",
"type": "module",
"scripts": {
"match": "bun run src/index.ts",
"test": "bun test",
"lint": "biome check .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"discord.js": "^14.25.1"
},
"devDependencies": {
"@biomejs/biome": "^2.3.12",
"@types/bun": "latest",
"typescript": "^5.9.3"
}
}
38 changes: 38 additions & 0 deletions src/discord.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Client, GatewayIntentBits } from "discord.js";
import type { Participant } from "./types.ts";

function getEnvOrThrow(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(`환경변수 ${key}가 설정되지 않았습니다.`);
}
return value;
}

const DISCORD_BOT_TOKEN = getEnvOrThrow("DISCORD_BOT_TOKEN");
const SERVER_ID = getEnvOrThrow("DISCORD_SERVER_ID");
const ROLE_ID = getEnvOrThrow("DISCORD_ROLE_ID");

export async function getParticipants(): Promise<Participant[]> {
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});

await client.login(DISCORD_BOT_TOKEN);

try {
const guild = await client.guilds.fetch(SERVER_ID);
const members = await guild.members.fetch();

const participants = members
.filter((member) => member.roles.cache.has(ROLE_ID) && !member.user.bot)
.map((member) => ({
id: member.user.id,
username: member.user.username,
}));

return participants;
} finally {
await client.destroy();
}
}
Loading