Skip to content
Open
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
18 changes: 2 additions & 16 deletions .github/workflows/match.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,25 @@ jobs:
- 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_WEBHOOK_URL_COFFEE: ${{ secrets.DISCORD_WEBHOOK_URL_COFFEE }}
DISCORD_SERVER_ID: ${{ vars.DISCORD_SERVER_ID }}
DISCORD_ROLE_ID: ${{ vars.DISCORD_ROLE_ID }}
run: bun run match

- name: Format generated files
if: steps.check-week.outputs.skip == 'false' || github.event_name == 'workflow_dispatch'
run: bun run format

- 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)-${{ github.run_id }}"
git checkout -b "$BRANCH_NAME"
git add data/history.json
git add data/
git diff --staged --quiet && exit 0
git commit -m "chore: update match history"
git push -u origin "$BRANCH_NAME"
Expand Down
File renamed without changes.
10 changes: 10 additions & 0 deletions data/roles.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[
{
"name": "coffee",
"displayName": "커피챗",
"roleId": "1465029948887531533",
"webhookEnvKey": "DISCORD_WEBHOOK_URL_COFFEE",
"schedule": "biweekly",
"groupSize": 2
}
]
9 changes: 4 additions & 5 deletions src/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@ function getEnvOrThrow(key: string): string {
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(roleId: string): Promise<Participant[]> {
const DISCORD_BOT_TOKEN = getEnvOrThrow("DISCORD_BOT_TOKEN");
const SERVER_ID = getEnvOrThrow("DISCORD_SERVER_ID");

export async function getParticipants(): Promise<Participant[]> {
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
Expand All @@ -25,7 +24,7 @@ export async function getParticipants(): Promise<Participant[]> {
const members = await guild.members.fetch();

const participants = members
.filter((member) => member.roles.cache.has(ROLE_ID) && !member.user.bot)
.filter((member) => member.roles.cache.has(roleId) && !member.user.bot)
.map((member) => ({
id: member.user.id,
username: member.user.username,
Expand Down
78 changes: 57 additions & 21 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,74 @@
import rolesConfig from "../data/roles.json";
import { getParticipants } from "./discord.ts";
import { createMatches, loadHistory, saveHistory } from "./matcher.ts";
import { shouldRunToday } from "./schedule.ts";
import type { RoleConfig } from "./types.ts";
import { announceMatches } from "./webhook.ts";

const roles = rolesConfig as RoleConfig[];

async function main() {
console.log("☕ 커피챗 매칭을 시작합니다...\n");

// 1. 참여자 목록 조회
const participants = await getParticipants();
console.log(`참여자 ${participants.length}명 조회 완료`);
for (const role of roles) {
console.log(`--- [${role.displayName}] 역할 처리 중 ---`);

if (participants.length < 2) {
console.log("참여자가 2명 미만이어서 매칭을 진행할 수 없습니다.");
return;
}
// 스케줄 체크
if (!shouldRunToday(role.schedule)) {
console.log(
`${role.displayName}: 이번 주는 매칭 주가 아닙니다. 건너뜁니다.`,
);
continue;
}

// 2. 매칭 이력 로드
const history = await loadHistory();
// 웹훅 URL 확인
const webhookUrl = process.env[role.webhookEnvKey];
if (!webhookUrl) {
console.error(
`환경변수 ${role.webhookEnvKey}가 설정되지 않았습니다. ${role.displayName} 건너뜁니다.`,
);
continue;
}

// 3. 매칭 생성
const pairs = createMatches(participants, history);
console.log(`${pairs.length}개 조 매칭 완료`);
// 1. 참여자 목록 조회
const participants = await getParticipants(role.roleId);
console.log(
`${role.displayName}: 참여자 ${participants.length}명 조회 완료`,
);

if (pairs.length === 0) {
console.log("생성된 매칭이 없어 Discord에 발표하지 않습니다.");
return;
}
if (participants.length < 2) {
console.log(
`${role.displayName}: 참여자가 2명 미만이어서 매칭을 진행할 수 없습니다.`,
);
continue;
}

// 2. 매칭 이력 로드
const history = await loadHistory(role.name);

// 4. 매칭 이력 저장
await saveHistory(history, pairs);
// 3. 매칭 생성
const groups = createMatches(participants, history, {
groupSize: role.groupSize,
});
console.log(`${role.displayName}: ${groups.length}개 조 매칭 완료`);

// 5. Discord에 발표
await announceMatches(pairs);
if (groups.length === 0) {
console.log(
`${role.displayName}: 생성된 매칭이 없어 Discord에 발표하지 않습니다.`,
);
continue;
}

// 4. 매칭 이력 저장
await saveHistory(role.name, history, groups);

// 5. Discord에 발표
await announceMatches(webhookUrl, groups, role.displayName);

console.log(`${role.displayName}: ✅ 매칭 완료!`);
}

console.log("\n✅ 커피챗 매칭이 완료되었습니다!");
console.log("\n✅ 모든 역할의 커피챗 매칭이 완료되었습니다!");
}

main().catch((error) => {
Expand Down
101 changes: 96 additions & 5 deletions src/matcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
buildGroupCandidates,
calculateExperienceStats,
calculatePairScore,
createMatches,
Expand Down Expand Up @@ -126,6 +127,70 @@ describe("createMatches", () => {
});
});

describe("createMatches with groupSize", () => {
test("groupSize=3일 때 3인 조로 매칭된다", () => {
const participants = createParticipants(6);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 3 });

expect(groups).toHaveLength(2);
expect(groups.every((g) => g.length === 3)).toBe(true);
});

test("groupSize=3이고 7명이면 3인조 2개 + 나머지 1명이 배치된다", () => {
const participants = createParticipants(7);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 3 });

expect(groups).toHaveLength(2);
const lengths = groups.map((g) => g.length).sort();
expect(lengths).toEqual([3, 4]);
});

test("groupSize=3이고 8명이면 나머지 2명이 배치된다", () => {
const participants = createParticipants(8);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 3 });

expect(groups).toHaveLength(2);
const total = groups.reduce((sum, g) => sum + g.length, 0);
expect(total).toBe(8);
});

test("groupSize=4일 때 4인 조로 매칭된다", () => {
const participants = createParticipants(8);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 4 });

expect(groups).toHaveLength(2);
expect(groups.every((g) => g.length === 4)).toBe(true);
});

test("groupSize보다 인원이 적으면 한 그룹으로", () => {
const participants = createParticipants(2);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 3 });

expect(groups).toHaveLength(1);
expect(groups[0]).toHaveLength(2);
});

test("groupSize=3이고 모든 참여자가 매칭에 포함된다", () => {
const participants = createParticipants(9);
const history: MatchHistory = { matches: [] };

const groups = createMatches(participants, history, { groupSize: 3 });
const matchedIds = groups.flat().map((p) => p.id);

expect(matchedIds.sort()).toEqual(participants.map((p) => p.id).sort());
});
});

describe("getMeetingCount", () => {
test("만난 적 없으면 0을 반환한다", () => {
const history: MatchHistory = { matches: [] };
Expand Down Expand Up @@ -302,15 +367,41 @@ describe("calculatePairScore", () => {
});
});

describe("buildGroupCandidates", () => {
test("groupSize=2일 때 모든 페어를 생성한다", () => {
const participants = createParticipants(3);
const history: MatchHistory = { matches: [] };
const stats = calculateExperienceStats(participants, history);

const candidates = buildGroupCandidates(participants, history, stats, 2);

// C(3,2) = 3
expect(candidates).toHaveLength(3);
expect(candidates.every((c) => c.ids.length === 2)).toBe(true);
});

test("groupSize=3일 때 모든 3인 조합을 생성한다", () => {
const participants = createParticipants(4);
const history: MatchHistory = { matches: [] };
const stats = calculateExperienceStats(participants, history);

const candidates = buildGroupCandidates(participants, history, stats, 3);

// C(4,3) = 4
expect(candidates).toHaveLength(4);
expect(candidates.every((c) => c.ids.length === 3)).toBe(true);
});
});

describe("scoresToProbabilities", () => {
test("빈 배열은 빈 배열 반환", () => {
expect(scoresToProbabilities([])).toEqual([]);
});

test("높은 점수가 높은 확률을 갖는다", () => {
const candidates = [
{ ids: ["a", "b"] as [string, string], score: 1.0 },
{ ids: ["c", "d"] as [string, string], score: 0.5 },
{ ids: ["a", "b"], score: 1.0 },
{ ids: ["c", "d"], score: 0.5 },
];

const probs = scoresToProbabilities(candidates, 0.5);
Expand All @@ -320,9 +411,9 @@ describe("scoresToProbabilities", () => {

test("확률의 합은 1이다", () => {
const candidates = [
{ ids: ["a", "b"] as [string, string], score: 0.8 },
{ ids: ["c", "d"] as [string, string], score: 0.6 },
{ ids: ["e", "f"] as [string, string], score: 0.4 },
{ ids: ["a", "b"], score: 0.8 },
{ ids: ["c", "d"], score: 0.6 },
{ ids: ["e", "f"], score: 0.4 },
];

const probs = scoresToProbabilities(candidates, 0.5);
Expand Down
Loading