From 1af7e8be98a3c45e4adea0ab918ad7044746d798 Mon Sep 17 00:00:00 2001 From: alphago2580 Date: Sun, 8 Feb 2026 15:12:36 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Discord-GitHub-Notion=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20Worker=20(TypeScript/Cloudflare=20Workers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub 이슈 → Discord 포럼 포스트 자동 생성 - GitHub 댓글 ↔ Discord 포스트 양방향 동기화 - /issue create: Discord에서 Modal 폼으로 GitHub 이슈 생성 (라벨 지원) - /comment: Discord에서 GitHub 이슈로 댓글 전송 - /sync: 현재 포스트의 연결된 GitHub 이슈 확인 - PR 이벤트 Discord 알림 (opened/closed/merged) - Notion 데이터베이스 동기화 (이슈 생성/상태 업데이트) - KV 기반 양방향 매핑 (issue ↔ post ↔ notion) - 슬래시 커맨드 등록 스크립트 추가 - CLAUDE.md 프로젝트 가이드 추가 Co-Authored-By: Claude Opus 4.6 --- discord-github-worker/.gitignore | 16 +- discord-github-worker/CLAUDE.md | 137 ++ discord-github-worker/package-lock.json | 1606 +++++++++++++++++ discord-github-worker/package.json | 16 + .../scripts/register-commands.js | 78 + discord-github-worker/src/discord.ts | 436 +++++ discord-github-worker/src/github.ts | 300 +++ discord-github-worker/src/index.ts | 63 + .../src/utils/discord-api.ts | 190 ++ discord-github-worker/src/utils/github-api.ts | 286 +++ discord-github-worker/src/utils/mapping.ts | 103 ++ discord-github-worker/src/utils/notion-api.ts | 163 ++ discord-github-worker/tsconfig.json | 17 + discord-github-worker/wrangler.toml | 27 + 14 files changed, 3437 insertions(+), 1 deletion(-) create mode 100644 discord-github-worker/CLAUDE.md create mode 100644 discord-github-worker/package-lock.json create mode 100644 discord-github-worker/package.json create mode 100644 discord-github-worker/scripts/register-commands.js create mode 100644 discord-github-worker/src/discord.ts create mode 100644 discord-github-worker/src/github.ts create mode 100644 discord-github-worker/src/index.ts create mode 100644 discord-github-worker/src/utils/discord-api.ts create mode 100644 discord-github-worker/src/utils/github-api.ts create mode 100644 discord-github-worker/src/utils/mapping.ts create mode 100644 discord-github-worker/src/utils/notion-api.ts create mode 100644 discord-github-worker/tsconfig.json create mode 100644 discord-github-worker/wrangler.toml diff --git a/discord-github-worker/.gitignore b/discord-github-worker/.gitignore index 731cce7..5c78d1d 100644 --- a/discord-github-worker/.gitignore +++ b/discord-github-worker/.gitignore @@ -1,4 +1,18 @@ -config.json +# Dependencies +node_modules/ + +# Wrangler +.wrangler/ +.dev.vars + +# Environment .env +config.json + +# Python (legacy) __pycache__/ *.pyc + +# OS +.DS_Store +Thumbs.db diff --git a/discord-github-worker/CLAUDE.md b/discord-github-worker/CLAUDE.md new file mode 100644 index 0000000..e04aff1 --- /dev/null +++ b/discord-github-worker/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Discord-GitHub-Notion 통합 Worker: Cloudflare Workers에서 실행되는 TypeScript 기반 서버리스 애플리케이션으로, GitHub 이슈와 Discord 포럼 포스트, Notion 데이터베이스를 양방향으로 동기화합니다. + +## Development Commands + +```bash +# 디렉토리 이동 +cd discord-github-worker + +# 로컬 개발 서버 실행 +npm run dev + +# Cloudflare Workers에 배포 +npm run deploy + +# 실시간 로그 확인 +npm run tail +``` + +## Configuration Management + +### Secrets 설정 (민감 정보) +```bash +wrangler secret put DISCORD_PUBLIC_KEY +wrangler secret put DISCORD_BOT_TOKEN +wrangler secret put GITHUB_PRIVATE_KEY +wrangler secret put GITHUB_WEBHOOK_SECRET +wrangler secret put NOTION_API_KEY # 선택사항 +``` + +### KV Namespace 설정 +```bash +# 최초 1회만 실행 +wrangler kv namespace create MAPPING + +# 출력된 ID를 wrangler.toml의 kv_namespaces.id에 입력 +``` + +### Environment Variables +`wrangler.toml`의 `[vars]` 섹션에서 설정: +- `GITHUB_APP_ID`: GitHub App ID +- `DISCORD_FORUM_CHANNEL_ID`: 이슈 동기화용 Discord 포럼 채널 ID +- `DISCORD_PR_CHANNEL_ID`: PR 알림용 Discord 채널 ID +- `NOTION_DATABASE_ID`: Notion 데이터베이스 ID (선택사항) + +## Architecture + +### Request Flow + +``` +┌─────────────┐ /discord ┌──────────────────┐ +│ Discord │ ──────────────────▶│ │ +│ Interaction │ │ │ +└─────────────┘ │ Cloudflare │ + │ Worker │ +┌─────────────┐ /github │ (index.ts) │ +│ GitHub │ ──────────────────▶│ │ +│ Webhook │ │ │ +└─────────────┘ └──────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ KV Namespace │ + │ (MAPPING) │ + └──────────────────┘ +``` + +### Core Components + +**src/index.ts**: 메인 엔트리포인트 +- `/discord`: Discord Interaction 엔드포인트 (서명 검증 + 커맨드 라우팅) +- `/github`: GitHub Webhook 엔드포인트 (서명 검증 + 이벤트 라우팅) +- `/health`: 헬스체크 + +**src/discord.ts**: Discord 이벤트 핸들러 +- `/sync`: 현재 포스트의 연결된 GitHub 이슈 확인 +- `/comment`: Discord에서 GitHub 이슈로 댓글 전송 (deferred response로 3초 제한 회피) +- `handleForumMessage()`: Gateway 이벤트 처리 (구현 예정) + +**src/github.ts**: GitHub Webhook 핸들러 +- `issues.opened`: Discord 포럼 포스트 + Notion 페이지 생성 +- `issues.closed/reopened`: Discord 알림 + Notion 상태 업데이트 +- `issue_comment.created`: GitHub 댓글 → Discord 포스트 (무한 루프 방지 로직 포함) +- `pull_request.*`: PR 알림 전송 (opened/closed/reopened/ready_for_review만 처리, draft 제외) + +**src/utils/mapping.ts**: KV 기반 양방향 매핑 +- `issue:{owner}/{repo}/{issueNumber}` → Discord 포스트 ID +- `post:{postId}` → `{owner}/{repo}/{issueNumber}` +- `notion:{owner}/{repo}/{issueNumber}` → Notion 페이지 ID + +**src/utils/discord-api.ts**: Discord API 클라이언트 +- `createForumPost()`: 포럼 채널에 새 포스트(스레드) 생성 +- `sendMessageToThread()`: 포스트에 댓글 추가 +- `sendPRNotification()`: Embed를 사용한 PR 알림 +- `verifyDiscordSignature()`: Ed25519 서명 검증 + +**src/utils/github-api.ts**: GitHub API 클라이언트 (GitHub App 인증) +- `createJWT()`: RS256 JWT 생성 (PKCS#1 → PKCS#8 자동 변환) +- `getInstallationToken()`: Repository별 Installation Access Token 획득 +- `addIssueComment()`: 이슈에 댓글 추가 +- `verifyGitHubSignature()`: HMAC-SHA256 서명 검증 + +**src/utils/notion-api.ts**: Notion API 클라이언트 +- `createNotionPage()`: 데이터베이스에 이슈 페이지 생성 +- `updateNotionPageStatus()`: 이슈 상태(Open/Closed) 업데이트 +- Notion 속성: 제목, 이슈번호, GitHub 링크, 상태, 작성자, 생성일 + +### Key Design Patterns + +**무한 루프 방지** +- Discord → GitHub 댓글: `**{username}** commented on Discord:` 접두사 추가 +- GitHub → Discord 댓글: 댓글 본문에 "commented on Discord:" 포함 시 무시 + +**서명 검증** +- Discord: Ed25519 (`x-signature-ed25519`, `x-signature-timestamp`) +- GitHub: HMAC-SHA256 (`x-hub-signature-256`) + +**비동기 작업 처리** +- `/comment` 명령: `InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE` 즉시 반환 후 `ctx.waitUntil()`로 백그라운드 작업 실행 +- 3초 Discord Interaction 제한 회피 + +**GitHub App 인증** +- PEM 포맷 private key를 PKCS#8로 변환하여 Web Crypto API 호환 +- Base64 인코딩된 PEM도 자동 디코딩 처리 +- Repository별로 동적 Installation Token 획득 (캐싱 없음) + +## Important Notes + +- Discord 메시지/타이틀 길이 제한: 2000자 (초과 시 자동 truncate) +- GitHub private key는 base64 인코딩 또는 PEM 형식 모두 지원 +- Notion 통합은 선택사항 (`NOTION_API_KEY`와 `NOTION_DATABASE_ID` 미설정 시 스킵) +- PR draft는 `opened` 이벤트에서 자동 무시됨 diff --git a/discord-github-worker/package-lock.json b/discord-github-worker/package-lock.json new file mode 100644 index 0000000..d16b456 --- /dev/null +++ b/discord-github-worker/package-lock.json @@ -0,0 +1,1606 @@ +{ + "name": "discord-github-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discord-github-worker", + "version": "1.0.0", + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.3.3", + "wrangler": "^3.24.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz", + "integrity": "sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "mime": "^3.0.0" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.0.2.tgz", + "integrity": "sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.14", + "workerd": "^1.20250124.0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250718.0.tgz", + "integrity": "sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250718.0.tgz", + "integrity": "sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250718.0.tgz", + "integrity": "sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250718.0.tgz", + "integrity": "sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250718.0.tgz", + "integrity": "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "4.20260124.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260124.0.tgz", + "integrity": "sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild-plugins/node-globals-polyfill": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz", + "integrity": "sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild-plugins/node-modules-polyfill": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.2.2.tgz", + "integrity": "sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==", + "dev": true, + "license": "ISC", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "rollup-plugin-node-polyfills": "^0.2.1" + }, + "peerDependencies": { + "esbuild": "*" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz", + "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz", + "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz", + "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz", + "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz", + "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz", + "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz", + "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz", + "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz", + "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz", + "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz", + "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz", + "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz", + "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz", + "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz", + "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz", + "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz", + "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz", + "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz", + "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz", + "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz", + "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz", + "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esbuild": { + "version": "0.17.19", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", + "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.19", + "@esbuild/android-arm64": "0.17.19", + "@esbuild/android-x64": "0.17.19", + "@esbuild/darwin-arm64": "0.17.19", + "@esbuild/darwin-x64": "0.17.19", + "@esbuild/freebsd-arm64": "0.17.19", + "@esbuild/freebsd-x64": "0.17.19", + "@esbuild/linux-arm": "0.17.19", + "@esbuild/linux-arm64": "0.17.19", + "@esbuild/linux-ia32": "0.17.19", + "@esbuild/linux-loong64": "0.17.19", + "@esbuild/linux-mips64el": "0.17.19", + "@esbuild/linux-ppc64": "0.17.19", + "@esbuild/linux-riscv64": "0.17.19", + "@esbuild/linux-s390x": "0.17.19", + "@esbuild/linux-x64": "0.17.19", + "@esbuild/netbsd-x64": "0.17.19", + "@esbuild/openbsd-x64": "0.17.19", + "@esbuild/sunos-x64": "0.17.19", + "@esbuild/win32-arm64": "0.17.19", + "@esbuild/win32-ia32": "0.17.19", + "@esbuild/win32-x64": "0.17.19" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", + "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/miniflare": { + "version": "3.20250718.3", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-3.20250718.3.tgz", + "integrity": "sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "acorn": "8.14.0", + "acorn-walk": "8.3.2", + "exit-hook": "2.2.1", + "glob-to-regexp": "0.4.1", + "stoppable": "1.1.0", + "undici": "^5.28.5", + "workerd": "1.20250718.0", + "ws": "8.18.0", + "youch": "3.3.4", + "zod": "3.22.3" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=16.13" + } + }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "dev": true, + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/rollup-plugin-inject": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", + "integrity": "sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==", + "deprecated": "This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject.", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1", + "magic-string": "^0.25.3", + "rollup-pluginutils": "^2.8.1" + } + }, + "node_modules/rollup-plugin-node-polyfills": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz", + "integrity": "sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rollup-plugin-inject": "^3.0.0" + } + }, + "node_modules/rollup-pluginutils": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", + "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^0.6.1" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.14", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.14.tgz", + "integrity": "sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "exsolve": "^1.0.1", + "ohash": "^2.0.10", + "pathe": "^2.0.3", + "ufo": "^1.5.4" + } + }, + "node_modules/workerd": { + "version": "1.20250718.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250718.0.tgz", + "integrity": "sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20250718.0", + "@cloudflare/workerd-darwin-arm64": "1.20250718.0", + "@cloudflare/workerd-linux-64": "1.20250718.0", + "@cloudflare/workerd-linux-arm64": "1.20250718.0", + "@cloudflare/workerd-windows-64": "1.20250718.0" + } + }, + "node_modules/wrangler": { + "version": "3.114.17", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-3.114.17.tgz", + "integrity": "sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.3.4", + "@cloudflare/unenv-preset": "2.0.2", + "@esbuild-plugins/node-globals-polyfill": "0.2.3", + "@esbuild-plugins/node-modules-polyfill": "0.2.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.17.19", + "miniflare": "3.20250718.3", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.14", + "workerd": "1.20250718.0" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=16.17.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2", + "sharp": "^0.33.5" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20250408.0" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^0.7.1", + "mustache": "^4.2.0", + "stacktracey": "^2.1.8" + } + }, + "node_modules/zod": { + "version": "3.22.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/discord-github-worker/package.json b/discord-github-worker/package.json new file mode 100644 index 0000000..0d9158d --- /dev/null +++ b/discord-github-worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "discord-github-worker", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "tail": "wrangler tail", + "register": "node scripts/register-commands.js" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.3.3", + "wrangler": "^3.24.0" + } +} diff --git a/discord-github-worker/scripts/register-commands.js b/discord-github-worker/scripts/register-commands.js new file mode 100644 index 0000000..8d53ddf --- /dev/null +++ b/discord-github-worker/scripts/register-commands.js @@ -0,0 +1,78 @@ +// Discord 슬래시 커맨드 등록 스크립트 +// 사용법: DISCORD_APP_ID=xxx DISCORD_BOT_TOKEN=xxx node scripts/register-commands.js + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +const commands = [ + { + name: 'sync', + description: '현재 포스트에 연결된 GitHub 이슈 정보를 확인합니다.', + type: 1, // CHAT_INPUT + }, + { + name: 'comment', + description: 'Discord에서 연결된 GitHub 이슈로 댓글을 전송합니다.', + type: 1, + options: [ + { + name: 'message', + description: 'GitHub 이슈에 전송할 댓글 내용', + type: 3, // STRING + required: true, + }, + ], + }, + { + name: 'link', + description: '현재 포스트를 GitHub 이슈에 수동으로 연결합니다. (준비 중)', + type: 1, + }, + { + name: 'issue', + description: 'GitHub 이슈를 관리합니다.', + type: 1, + options: [ + { + name: 'create', + description: '새 GitHub 이슈를 생성합니다.', + type: 1, // SUB_COMMAND + }, + ], + }, +]; + +async function registerCommands() { + const appId = process.env.DISCORD_APP_ID; + const token = process.env.DISCORD_BOT_TOKEN; + + if (!appId || !token) { + console.error('환경변수를 설정해주세요:'); + console.error(' DISCORD_APP_ID=xxx DISCORD_BOT_TOKEN=xxx node scripts/register-commands.js'); + process.exit(1); + } + + const url = `${DISCORD_API_BASE}/applications/${appId}/commands`; + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Authorization': `Bot ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(commands), + }); + + if (!response.ok) { + const error = await response.text(); + console.error(`등록 실패 (${response.status}):`, error); + process.exit(1); + } + + const result = await response.json(); + console.log(`✓ ${result.length}개 슬래시 커맨드 등록 완료:`); + result.forEach((cmd) => { + console.log(` /${cmd.name} - ${cmd.description}`); + }); +} + +registerCommands(); diff --git a/discord-github-worker/src/discord.ts b/discord-github-worker/src/discord.ts new file mode 100644 index 0000000..ce7c750 --- /dev/null +++ b/discord-github-worker/src/discord.ts @@ -0,0 +1,436 @@ +// Discord 이벤트 핸들러 + +import { verifyDiscordSignature } from './utils/discord-api'; +import { addIssueComment, createIssue } from './utils/github-api'; +import { getIssueFromPost } from './utils/mapping'; + +interface Env { + MAPPING: KVNamespace; + DISCORD_PUBLIC_KEY: string; + DISCORD_BOT_TOKEN: string; + GITHUB_APP_ID: string; + GITHUB_PRIVATE_KEY: string; + GITHUB_OWNER?: string; + GITHUB_REPO?: string; +} + +interface DiscordInteraction { + type: number; + token: string; + application_id: string; + channel_id?: string; + channel?: { + id: string; + parent_id?: string; + type: number; + }; + member?: { + user: { + id: string; + username: string; + global_name?: string; + }; + }; + user?: { + id: string; + username: string; + global_name?: string; + }; + data?: { + name: string; + options?: Array<{ name: string; value: string; options?: Array<{ name: string; value: string }> }>; + custom_id?: string; + components?: Array<{ + type: number; + components: Array<{ + type: number; + custom_id: string; + value: string; + }>; + }>; + }; + message?: { + id: string; + content: string; + channel_id: string; + }; +} + +// Discord Interaction 타입 +const InteractionType = { + PING: 1, + APPLICATION_COMMAND: 2, + MESSAGE_COMPONENT: 3, + MODAL_SUBMIT: 5, +}; + +// Discord Response 타입 +const InteractionResponseType = { + PONG: 1, + CHANNEL_MESSAGE_WITH_SOURCE: 4, + DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5, + MODAL: 9, +}; + +export async function handleDiscordRequest( + request: Request, + env: Env, + ctx: ExecutionContext +): Promise { + // 서명 검증 + const signature = request.headers.get('x-signature-ed25519'); + const timestamp = request.headers.get('x-signature-timestamp'); + const body = await request.text(); + + if (!signature || !timestamp) { + return new Response('Missing signature headers', { status: 401 }); + } + + const isValid = await verifyDiscordSignature( + env.DISCORD_PUBLIC_KEY, + signature, + timestamp, + body + ); + + if (!isValid) { + return new Response('Invalid signature', { status: 401 }); + } + + const interaction: DiscordInteraction = JSON.parse(body); + + // PING 응답 (Discord가 URL 검증할 때 사용) + if (interaction.type === InteractionType.PING) { + return jsonResponse({ type: InteractionResponseType.PONG }); + } + + // 슬래시 커맨드 처리 + if (interaction.type === InteractionType.APPLICATION_COMMAND) { + return await handleCommand(interaction, env, ctx); + } + + // Modal 제출 처리 + if (interaction.type === InteractionType.MODAL_SUBMIT) { + return await handleModalSubmit(interaction, env, ctx); + } + + return new Response('Unknown interaction type', { status: 400 }); +} + +async function handleCommand( + interaction: DiscordInteraction, + env: Env, + ctx: ExecutionContext +): Promise { + const commandName = interaction.data?.name; + + switch (commandName) { + case 'sync': + return await handleSyncCommand(interaction, env); + case 'link': + return await handleLinkCommand(interaction, env); + case 'comment': + return await handleCommentCommand(interaction, env, ctx); + case 'issue': + return await handleIssueCommand(interaction, env); + default: + return jsonResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Unknown command: ${commandName}`, + flags: 64, // Ephemeral + }, + }); + } +} + +// /sync 명령어: 현재 포스트의 GitHub 이슈 정보 확인 +async function handleSyncCommand( + interaction: DiscordInteraction, + env: Env +): Promise { + const channelId = interaction.channel_id; + if (!channelId) { + return ephemeralResponse('Could not determine channel.'); + } + + const mapping = await getIssueFromPost(env.MAPPING, channelId); + if (!mapping) { + return ephemeralResponse('This post is not linked to any GitHub issue.'); + } + + const { owner, repo, issueNumber } = mapping; + const issueUrl = `https://github.com/${owner}/${repo}/issues/${issueNumber}`; + + return ephemeralResponse( + `This post is linked to GitHub issue: [#${issueNumber}](${issueUrl})` + ); +} + +// /link 명령어: 수동으로 이슈와 포스트 연결 +async function handleLinkCommand( + interaction: DiscordInteraction, + env: Env +): Promise { + // 이 기능은 나중에 구현 가능 + return ephemeralResponse( + 'Manual linking is not yet implemented. Issues are automatically linked when created from GitHub.' + ); +} + +// /comment 명령어: Discord에서 GitHub 이슈로 코멘트 전송 (지연 응답 사용) +async function handleCommentCommand( + interaction: DiscordInteraction, + env: Env, + ctx: ExecutionContext +): Promise { + const channelId = interaction.channel_id; + if (!channelId) { + return ephemeralResponse('Could not determine channel.'); + } + + // 이 포스트가 GitHub 이슈와 연결되어 있는지 확인 + const mapping = await getIssueFromPost(env.MAPPING, channelId); + if (!mapping) { + return ephemeralResponse('This post is not linked to any GitHub issue. Only posts created from GitHub issues can sync comments.'); + } + + // 코멘트 내용 가져오기 + const commentText = interaction.data?.options?.find(opt => opt.name === 'message')?.value; + if (!commentText) { + return ephemeralResponse('Please provide a comment message.'); + } + + // 사용자 정보 + const user = interaction.member?.user || interaction.user; + const username = user?.global_name || user?.username || 'Unknown'; + + const { owner, repo, issueNumber } = mapping; + + // 백그라운드에서 GitHub API 호출 (지연 응답 후) + const token = interaction.token; + const appId = interaction.application_id; + + // 비동기로 GitHub에 코멘트 전송 및 follow-up 메시지 전송 + const backgroundTask = (async () => { + const commentBody = `**${username}** commented on Discord:\n\n${commentText}`; + + const result = await addIssueComment( + env.GITHUB_APP_ID, + env.GITHUB_PRIVATE_KEY, + owner, + repo, + issueNumber, + commentBody + ); + + // Follow-up 메시지 전송 + const followUpUrl = `https://discord.com/api/v10/webhooks/${appId}/${token}/messages/@original`; + const content = result + ? `💬 **${username}**: ${commentText}\n\n_→ Synced to [GitHub issue #${issueNumber}](https://github.com/${owner}/${repo}/issues/${issueNumber})_` + : `❌ Failed to send comment to GitHub. Please try again.`; + + await fetch(followUpUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + })(); + + // 백그라운드 작업 시작 (응답과 별개로 실행) + ctx.waitUntil(backgroundTask); + + // 즉시 "생각 중" 응답 반환 (3초 제한 회피) + return jsonResponse({ + type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + }); +} + +// /issue create 명령어: Modal 폼을 띄워서 GitHub 이슈 생성 +async function handleIssueCommand( + interaction: DiscordInteraction, + env: Env +): Promise { + const subcommand = interaction.data?.options?.[0]?.name; + + if (subcommand !== 'create') { + return ephemeralResponse(`Unknown subcommand: ${subcommand}`); + } + + // Modal 폼 띄우기 + return jsonResponse({ + type: InteractionResponseType.MODAL, + data: { + custom_id: 'issue_create_modal', + title: 'GitHub 이슈 생성', + components: [ + { + type: 1, // Action Row + components: [ + { + type: 4, // Text Input + custom_id: 'issue_title', + label: '제목', + style: 1, // Short + placeholder: '이슈 제목을 입력하세요', + required: true, + max_length: 256, + }, + ], + }, + { + type: 1, + components: [ + { + type: 4, + custom_id: 'issue_body', + label: '내용', + style: 2, // Paragraph + placeholder: '이슈 내용을 입력하세요 (선택사항)', + required: false, + max_length: 4000, + }, + ], + }, + { + type: 1, + components: [ + { + type: 4, + custom_id: 'issue_labels', + label: '태그 (쉼표로 구분)', + style: 1, // Short + placeholder: 'bug, enhancement, documentation', + required: false, + max_length: 200, + }, + ], + }, + ], + }, + }); +} + +// Modal 제출 처리 +async function handleModalSubmit( + interaction: DiscordInteraction, + env: Env, + ctx: ExecutionContext +): Promise { + const customId = interaction.data?.custom_id; + + if (customId !== 'issue_create_modal') { + return ephemeralResponse('Unknown modal.'); + } + + // Modal에서 입력값 추출 + const components = interaction.data?.components || []; + let title = ''; + let body = ''; + let labelsRaw = ''; + + for (const row of components) { + for (const comp of row.components) { + if (comp.custom_id === 'issue_title') title = comp.value; + if (comp.custom_id === 'issue_body') body = comp.value; + if (comp.custom_id === 'issue_labels') labelsRaw = comp.value; + } + } + + const labels = labelsRaw + ? labelsRaw.split(',').map(l => l.trim()).filter(Boolean) + : []; + + if (!title) { + return ephemeralResponse('제목을 입력해주세요.'); + } + + const user = interaction.member?.user || interaction.user; + const username = user?.global_name || user?.username || 'Unknown'; + + const owner = (env as any).GITHUB_OWNER || 'alphago2580'; + const repo = (env as any).GITHUB_REPO || 'MESA'; + + const token = interaction.token; + const appId = interaction.application_id; + + const backgroundTask = (async () => { + const issueBody = body + ? `${body}\n\n---\n_Created from Discord by **${username}**_` + : `_Created from Discord by **${username}**_`; + + const result = await createIssue( + env.GITHUB_APP_ID, + env.GITHUB_PRIVATE_KEY, + owner, + repo, + title, + issueBody, + labels + ); + + const followUpUrl = `https://discord.com/api/v10/webhooks/${appId}/${token}/messages/@original`; + const content = result + ? `✅ GitHub 이슈 생성 완료!\n**#${result.number}** ${title}\n${result.html_url}` + : `❌ 이슈 생성에 실패했습니다. 다시 시도해주세요.`; + + await fetch(followUpUrl, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + })(); + + ctx.waitUntil(backgroundTask); + + return jsonResponse({ + type: InteractionResponseType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + }); +} + +// 포럼 포스트 메시지 이벤트 처리 (Gateway 이벤트 - 별도 구현 필요) +export async function handleForumMessage( + postId: string, + authorName: string, + content: string, + env: Env +): Promise { + // Discord Gateway를 통해 받은 메시지 이벤트 처리 + // 포럼 포스트에 새 메시지가 오면 GitHub 이슈에 코멘트 추가 + + const mapping = await getIssueFromPost(env.MAPPING, postId); + if (!mapping) { + console.log('No mapping found for post:', postId); + return; + } + + const { owner, repo, issueNumber } = mapping; + + // GitHub에 코멘트 추가 + const commentBody = `**${authorName}** commented on Discord:\n\n${content}`; + + await addIssueComment( + env.GITHUB_APP_ID, + env.GITHUB_PRIVATE_KEY, + owner, + repo, + issueNumber, + commentBody + ); +} + +function jsonResponse(data: object): Response { + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' }, + }); +} + +function ephemeralResponse(content: string): Response { + return jsonResponse({ + type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content, + flags: 64, // Ephemeral - only visible to the user + }, + }); +} diff --git a/discord-github-worker/src/github.ts b/discord-github-worker/src/github.ts new file mode 100644 index 0000000..4b739db --- /dev/null +++ b/discord-github-worker/src/github.ts @@ -0,0 +1,300 @@ +// GitHub Webhook 핸들러 + +import { verifyGitHubSignature } from './utils/github-api'; +import { + createForumPost, + sendMessageToThread, + sendPRNotification, +} from './utils/discord-api'; +import { getDiscordPostId, createMapping, getNotionPageId, createNotionMapping } from './utils/mapping'; +import { createNotionPage, updateNotionPageStatus } from './utils/notion-api'; + +interface Env { + MAPPING: KVNamespace; + DISCORD_BOT_TOKEN: string; + GITHUB_WEBHOOK_SECRET: string; + DISCORD_FORUM_CHANNEL_ID?: string; // 이슈용 포럼 채널 + DISCORD_PR_CHANNEL_ID?: string; // PR 알림 채널 + NOTION_API_KEY?: string; // Notion Integration 토큰 (레거시) + NOTION_API_TOKEN?: string; // Notion Integration 토큰 + NOTION_DATABASE_ID?: string; // Notion 데이터베이스 ID (vars) + NOTION_DATA_SOURCE_ID?: string; // Notion 데이터베이스 ID (secret) +} + +interface GitHubUser { + login: string; + avatar_url: string; + html_url: string; +} + +interface GitHubIssue { + number: number; + title: string; + body?: string; + html_url: string; + user: GitHubUser; + labels?: Array<{ name: string; color: string }>; + created_at?: string; +} + +interface GitHubComment { + id: number; + body: string; + html_url: string; + user: GitHubUser; +} + +interface GitHubPR { + number: number; + title: string; + body?: string; + html_url: string; + user: GitHubUser; + merged?: boolean; + draft?: boolean; +} + +interface GitHubRepository { + name: string; + full_name: string; + owner: { + login: string; + }; +} + +interface GitHubWebhookPayload { + action?: string; + issue?: GitHubIssue; + comment?: GitHubComment; + pull_request?: GitHubPR; + repository: GitHubRepository; + sender: GitHubUser; +} + +export async function handleGitHubWebhook( + request: Request, + env: Env +): Promise { + // 서명 검증 + const signature = request.headers.get('x-hub-signature-256'); + const event = request.headers.get('x-github-event'); + const body = await request.text(); + + if (!signature) { + return new Response('Missing signature', { status: 401 }); + } + + const isValid = await verifyGitHubSignature( + env.GITHUB_WEBHOOK_SECRET, + signature, + body + ); + + if (!isValid) { + return new Response('Invalid signature', { status: 401 }); + } + + const payload: GitHubWebhookPayload = JSON.parse(body); + + console.log(`Received GitHub event: ${event}, action: ${payload.action}`); + + switch (event) { + case 'issues': + return await handleIssueEvent(payload, env); + case 'issue_comment': + return await handleIssueCommentEvent(payload, env); + case 'pull_request': + return await handlePREvent(payload, env); + default: + return new Response(`Unhandled event: ${event}`, { status: 200 }); + } +} + +async function handleIssueEvent( + payload: GitHubWebhookPayload, + env: Env +): Promise { + const { action, issue, repository } = payload; + if (!issue) return new Response('No issue in payload', { status: 400 }); + + const owner = repository.owner.login; + const repo = repository.name; + + if (action === 'opened') { + // 새 이슈 → Discord 포럼 포스트 생성 + const forumChannelId = env.DISCORD_FORUM_CHANNEL_ID; + if (!forumChannelId) { + console.error('DISCORD_FORUM_CHANNEL_ID not configured'); + return new Response('Forum channel not configured', { status: 500 }); + } + + const title = `[#${issue.number}] ${issue.title}`; + const content = formatIssueContent(issue, repository); + + const post = await createForumPost( + env.DISCORD_BOT_TOKEN, + forumChannelId, + title, + content + ); + + if (post) { + // 매핑 저장 + await createMapping(env.MAPPING, owner, repo, issue.number, post.id); + console.log(`Created Discord post ${post.id} for issue #${issue.number}`); + } + + // 새 이슈 → Notion 데이터베이스에 추가 + const notionApiKey = env.NOTION_API_TOKEN || env.NOTION_API_KEY; + const notionDbId = env.NOTION_DATA_SOURCE_ID || env.NOTION_DATABASE_ID; + if (notionApiKey && notionDbId) { + const notionPage = await createNotionPage( + notionApiKey, + notionDbId, + { + title: `[#${issue.number}] ${issue.title}`, + issueNumber: issue.number, + githubUrl: issue.html_url, + state: 'open', + author: issue.user.login, + createdAt: issue.created_at ? issue.created_at.split('T')[0] : new Date().toISOString().split('T')[0], + } + ); + + if (notionPage) { + await createNotionMapping(env.MAPPING, owner, repo, issue.number, notionPage.id); + console.log(`Created Notion page ${notionPage.id} for issue #${issue.number}`); + } + } else { + console.log('Notion integration not configured, skipping'); + } + + return new Response('Issue created and synced', { status: 200 }); + } + + if (action === 'closed' || action === 'reopened') { + // 이슈 상태 변경 → Discord에 알림 + const postId = await getDiscordPostId(env.MAPPING, owner, repo, issue.number); + if (postId) { + const status = action === 'closed' ? 'closed' : 're-opened'; + await sendMessageToThread( + env.DISCORD_BOT_TOKEN, + postId, + `Issue #${issue.number} was ${status} by **${payload.sender.login}**` + ); + } + + // 이슈 상태 변경 → Notion 상태 업데이트 + const notionKey = env.NOTION_API_TOKEN || env.NOTION_API_KEY; + if (notionKey) { + const notionPageId = await getNotionPageId(env.MAPPING, owner, repo, issue.number); + if (notionPageId) { + const newState = action === 'closed' ? 'closed' : 'open'; + await updateNotionPageStatus(notionKey, notionPageId, newState); + console.log(`Updated Notion page ${notionPageId} status to ${newState}`); + } + } + + return new Response(`Issue ${action} notification sent`, { status: 200 }); + } + + return new Response(`Unhandled issue action: ${action}`, { status: 200 }); +} + +async function handleIssueCommentEvent( + payload: GitHubWebhookPayload, + env: Env +): Promise { + const { action, issue, comment, repository } = payload; + if (!issue || !comment) { + return new Response('Missing issue or comment', { status: 400 }); + } + + // 새 코멘트만 처리 (편집/삭제는 무시) + if (action !== 'created') { + return new Response(`Ignoring comment action: ${action}`, { status: 200 }); + } + + // Discord에서 온 코멘트는 무시 (무한 루프 방지) + if (comment.body.includes('commented on Discord:')) { + return new Response('Ignoring comment from Discord sync', { status: 200 }); + } + + const owner = repository.owner.login; + const repo = repository.name; + + const postId = await getDiscordPostId(env.MAPPING, owner, repo, issue.number); + if (!postId) { + console.log(`No Discord post found for issue #${issue.number}`); + return new Response('No mapping found', { status: 200 }); + } + + // GitHub 코멘트 → Discord 포스트 댓글 + const content = formatCommentContent(comment); + await sendMessageToThread(env.DISCORD_BOT_TOKEN, postId, content); + + console.log(`Synced comment to Discord post ${postId}`); + return new Response('Comment synced', { status: 200 }); +} + +async function handlePREvent( + payload: GitHubWebhookPayload, + env: Env +): Promise { + const { action, pull_request } = payload; + if (!pull_request) { + return new Response('No PR in payload', { status: 400 }); + } + + const prChannelId = env.DISCORD_PR_CHANNEL_ID; + if (!prChannelId) { + console.error('DISCORD_PR_CHANNEL_ID not configured'); + return new Response('PR channel not configured', { status: 500 }); + } + + // 주요 PR 이벤트만 알림 + const notifyActions = ['opened', 'closed', 'reopened', 'ready_for_review']; + if (!notifyActions.includes(action || '')) { + return new Response(`Ignoring PR action: ${action}`, { status: 200 }); + } + + // Draft PR은 무시 + if (pull_request.draft && action === 'opened') { + return new Response('Ignoring draft PR', { status: 200 }); + } + + await sendPRNotification(env.DISCORD_BOT_TOKEN, prChannelId, { + title: pull_request.title, + number: pull_request.number, + html_url: pull_request.html_url, + user: pull_request.user, + body: pull_request.body, + action: action || 'unknown', + merged: pull_request.merged, + }); + + console.log(`PR #${pull_request.number} notification sent`); + return new Response('PR notification sent', { status: 200 }); +} + +function formatIssueContent(issue: GitHubIssue, repo: GitHubRepository): string { + const parts = [ + `**New issue opened by [@${issue.user.login}](${issue.user.html_url})**`, + '', + issue.body?.slice(0, 1500) || '*No description provided*', + '', + `[View on GitHub](${issue.html_url})`, + ]; + return parts.join('\n'); +} + +function formatCommentContent(comment: GitHubComment): string { + const parts = [ + `**[@${comment.user.login}](${comment.user.html_url})** commented:`, + '', + comment.body.slice(0, 1800), + '', + `[View on GitHub](${comment.html_url})`, + ]; + return parts.join('\n'); +} diff --git a/discord-github-worker/src/index.ts b/discord-github-worker/src/index.ts new file mode 100644 index 0000000..33f9662 --- /dev/null +++ b/discord-github-worker/src/index.ts @@ -0,0 +1,63 @@ +// Discord-GitHub 연동 Worker 메인 엔트리포인트 + +import { handleDiscordRequest } from './discord'; +import { handleGitHubWebhook } from './github'; + +export interface Env { + MAPPING: KVNamespace; + DISCORD_PUBLIC_KEY: string; + DISCORD_BOT_TOKEN: string; + GITHUB_APP_ID: string; + GITHUB_PRIVATE_KEY: string; + GITHUB_WEBHOOK_SECRET: string; + DISCORD_FORUM_CHANNEL_ID?: string; + DISCORD_PR_CHANNEL_ID?: string; +} + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + const url = new URL(request.url); + + // CORS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, X-Hub-Signature-256, X-GitHub-Event, X-Signature-Ed25519, X-Signature-Timestamp', + }, + }); + } + + // 라우팅 + try { + switch (url.pathname) { + case '/discord': + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + return await handleDiscordRequest(request, env, ctx); + + case '/github': + if (request.method !== 'POST') { + return new Response('Method not allowed', { status: 405 }); + } + return await handleGitHubWebhook(request, env); + + case '/health': + return new Response(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString() }), { + headers: { 'Content-Type': 'application/json' }, + }); + + default: + return new Response('Not Found', { status: 404 }); + } + } catch (error) { + console.error('Error handling request:', error); + return new Response( + JSON.stringify({ error: 'Internal Server Error', message: String(error) }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + }, +}; diff --git a/discord-github-worker/src/utils/discord-api.ts b/discord-github-worker/src/utils/discord-api.ts new file mode 100644 index 0000000..756d4f7 --- /dev/null +++ b/discord-github-worker/src/utils/discord-api.ts @@ -0,0 +1,190 @@ +// Discord API 호출 유틸리티 + +const DISCORD_API_BASE = 'https://discord.com/api/v10'; + +interface DiscordEnv { + DISCORD_BOT_TOKEN: string; +} + +async function discordFetch( + endpoint: string, + token: string, + options: RequestInit = {} +): Promise { + const url = `${DISCORD_API_BASE}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bot ${token}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + return response; +} + +// 포럼 채널에 새 포스트(스레드) 생성 +export async function createForumPost( + token: string, + forumChannelId: string, + title: string, + content: string, + appliedTags?: string[] +): Promise<{ id: string; message_id: string } | null> { + const response = await discordFetch( + `/channels/${forumChannelId}/threads`, + token, + { + method: 'POST', + body: JSON.stringify({ + name: title.slice(0, 100), // Discord 제한: 100자 + message: { + content: content.slice(0, 2000), // Discord 제한: 2000자 + }, + applied_tags: appliedTags, + }), + } + ); + + if (!response.ok) { + console.error('Failed to create forum post:', await response.text()); + return null; + } + + const data = await response.json() as { id: string; last_message_id: string }; + return { id: data.id, message_id: data.last_message_id }; +} + +// 스레드(포스트)에 메시지 전송 +export async function sendMessageToThread( + token: string, + threadId: string, + content: string +): Promise<{ id: string } | null> { + const response = await discordFetch( + `/channels/${threadId}/messages`, + token, + { + method: 'POST', + body: JSON.stringify({ + content: content.slice(0, 2000), + }), + } + ); + + if (!response.ok) { + console.error('Failed to send message:', await response.text()); + return null; + } + + return await response.json() as { id: string }; +} + +// 일반 채널에 메시지 전송 (PR 알림용) +export async function sendMessageToChannel( + token: string, + channelId: string, + content: string, + embeds?: object[] +): Promise<{ id: string } | null> { + const response = await discordFetch( + `/channels/${channelId}/messages`, + token, + { + method: 'POST', + body: JSON.stringify({ + content: content?.slice(0, 2000), + embeds, + }), + } + ); + + if (!response.ok) { + console.error('Failed to send channel message:', await response.text()); + return null; + } + + return await response.json() as { id: string }; +} + +// Embed를 사용한 PR 알림 전송 +export async function sendPRNotification( + token: string, + channelId: string, + pr: { + title: string; + number: number; + html_url: string; + user: { login: string; avatar_url: string }; + body?: string; + action: string; + merged?: boolean; + } +): Promise<{ id: string } | null> { + const actionText = pr.merged ? 'merged' : pr.action; + const colors: Record = { + opened: 0x238636, // green + closed: 0xda3633, // red + merged: 0x8957e5, // purple + reopened: 0x238636, // green + }; + + const embed = { + title: `PR #${pr.number}: ${pr.title}`, + url: pr.html_url, + description: pr.body?.slice(0, 300) || '', + color: colors[actionText] || 0x586069, + author: { + name: pr.user.login, + icon_url: pr.user.avatar_url, + }, + footer: { + text: `Pull Request ${actionText}`, + }, + timestamp: new Date().toISOString(), + }; + + return await sendMessageToChannel(token, channelId, '', [embed]); +} + +// Discord Interaction 서명 검증 +export async function verifyDiscordSignature( + publicKey: string, + signature: string, + timestamp: string, + body: string +): Promise { + try { + const encoder = new TextEncoder(); + const message = encoder.encode(timestamp + body); + + const signatureBytes = hexToUint8Array(signature); + const publicKeyBytes = hexToUint8Array(publicKey); + + const key = await crypto.subtle.importKey( + 'raw', + publicKeyBytes, + { name: 'Ed25519', namedCurve: 'Ed25519' }, + false, + ['verify'] + ); + + return await crypto.subtle.verify( + 'Ed25519', + key, + signatureBytes, + message + ); + } catch (error) { + console.error('Signature verification failed:', error); + return false; + } +} + +function hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); + } + return bytes; +} diff --git a/discord-github-worker/src/utils/github-api.ts b/discord-github-worker/src/utils/github-api.ts new file mode 100644 index 0000000..e9fd0e8 --- /dev/null +++ b/discord-github-worker/src/utils/github-api.ts @@ -0,0 +1,286 @@ +// GitHub API 호출 유틸리티 (GitHub App 인증) + +const GITHUB_API_BASE = 'https://api.github.com'; + +// JWT 생성을 위한 Base64URL 인코딩 +function base64urlEncode(data: Uint8Array | string): string { + const str = typeof data === 'string' ? data : String.fromCharCode(...data); + return btoa(str) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +// PEM 형식의 private key 파싱 (PKCS#1 → PKCS#8 변환 포함) +function pemToArrayBuffer(pem: string): ArrayBuffer { + // 앞뒤 공백 제거 + let decodedPem = pem.trim(); + + console.log('PEM input length:', decodedPem.length); + console.log('PEM starts with:', decodedPem.substring(0, 30)); + + // base64로 인코딩된 PEM인 경우 먼저 디코딩 + if (!decodedPem.includes('-----BEGIN')) { + try { + // 혹시 있을 수 있는 특수문자 제거 + decodedPem = decodedPem.replace(/[\r\n\s]/g, ''); + console.log('Cleaned PEM length:', decodedPem.length); + decodedPem = atob(decodedPem); + console.log('Decoded PEM length:', decodedPem.length); + console.log('Decoded PEM starts with:', decodedPem.substring(0, 30)); + } catch (e) { + console.error('Failed to decode base64 PEM:', e); + console.error('First 50 chars:', decodedPem.substring(0, 50)); + throw e; + } + } + + const isPkcs1 = decodedPem.includes('BEGIN RSA PRIVATE KEY'); + + const base64 = decodedPem + .replace(/-----BEGIN RSA PRIVATE KEY-----/, '') + .replace(/-----END RSA PRIVATE KEY-----/, '') + .replace(/-----BEGIN PRIVATE KEY-----/, '') + .replace(/-----END PRIVATE KEY-----/, '') + .replace(/[\r\n\s]/g, ''); + + // base64 패딩 보정 + let paddedBase64 = base64; + const remainder = base64.length % 4; + if (remainder > 0) { + paddedBase64 += '='.repeat(4 - remainder); + } + + console.log('Key base64 length:', base64.length, '-> padded:', paddedBase64.length); + + let binary: string; + try { + binary = atob(paddedBase64); + } catch (e) { + console.error('Failed to decode key base64:', e); + throw e; + } + const pkcs1Bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + pkcs1Bytes[i] = binary.charCodeAt(i); + } + + if (!isPkcs1) { + return pkcs1Bytes.buffer; + } + + // PKCS#1 → PKCS#8 변환 + // PKCS#8 헤더: SEQUENCE { version, algorithm, key } + const pkcs8Header = new Uint8Array([ + 0x30, 0x82, 0x00, 0x00, // SEQUENCE, length placeholder + 0x02, 0x01, 0x00, // INTEGER version = 0 + 0x30, 0x0d, // SEQUENCE (algorithm) + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, // OID rsaEncryption + 0x05, 0x00, // NULL + 0x04, 0x82, 0x00, 0x00 // OCTET STRING, length placeholder + ]); + + const totalLen = pkcs8Header.length + pkcs1Bytes.length - 4; + const keyLen = pkcs1Bytes.length; + + // 전체 길이 설정 (Big Endian) + pkcs8Header[2] = ((totalLen >> 8) & 0xff); + pkcs8Header[3] = (totalLen & 0xff); + + // OCTET STRING 길이 설정 + pkcs8Header[24] = ((keyLen >> 8) & 0xff); + pkcs8Header[25] = (keyLen & 0xff); + + const pkcs8Bytes = new Uint8Array(pkcs8Header.length + pkcs1Bytes.length); + pkcs8Bytes.set(pkcs8Header); + pkcs8Bytes.set(pkcs1Bytes, pkcs8Header.length); + + return pkcs8Bytes.buffer; +} + +// GitHub App JWT 생성 +async function createJWT(appId: string, privateKey: string): Promise { + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now - 60, // 60초 전 + exp: now + 10 * 60, // 10분 후 + iss: appId, + }; + + const header = { alg: 'RS256', typ: 'JWT' }; + const headerB64 = base64urlEncode(JSON.stringify(header)); + const payloadB64 = base64urlEncode(JSON.stringify(payload)); + const message = `${headerB64}.${payloadB64}`; + + const keyData = pemToArrayBuffer(privateKey); + const key = await crypto.subtle.importKey( + 'pkcs8', + keyData, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const signature = await crypto.subtle.sign( + 'RSASSA-PKCS1-v1_5', + key, + new TextEncoder().encode(message) + ); + + const signatureB64 = base64urlEncode(new Uint8Array(signature)); + return `${message}.${signatureB64}`; +} + +// Installation access token 획득 +async function getInstallationToken( + appId: string, + privateKey: string, + owner: string, + repo: string +): Promise { + const jwt = await createJWT(appId, privateKey); + + // 먼저 installation ID 획득 + const installResponse = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/installation`, + { + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'discord-github-worker', + }, + } + ); + + if (!installResponse.ok) { + console.error('Failed to get installation:', await installResponse.text()); + return null; + } + + const installation = await installResponse.json() as { id: number }; + + // Access token 생성 + const tokenResponse = await fetch( + `${GITHUB_API_BASE}/app/installations/${installation.id}/access_tokens`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${jwt}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'discord-github-worker', + }, + } + ); + + if (!tokenResponse.ok) { + console.error('Failed to get access token:', await tokenResponse.text()); + return null; + } + + const tokenData = await tokenResponse.json() as { token: string }; + return tokenData.token; +} + +// GitHub 이슈에 코멘트 추가 +export async function addIssueComment( + appId: string, + privateKey: string, + owner: string, + repo: string, + issueNumber: number, + body: string +): Promise<{ id: number } | null> { + const token = await getInstallationToken(appId, privateKey, owner, repo); + if (!token) return null; + + const response = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + 'User-Agent': 'discord-github-worker', + }, + body: JSON.stringify({ body }), + } + ); + + if (!response.ok) { + console.error('Failed to add comment:', await response.text()); + return null; + } + + return await response.json() as { id: number }; +} + +// GitHub 이슈 생성 +export async function createIssue( + appId: string, + privateKey: string, + owner: string, + repo: string, + title: string, + body: string, + labels: string[] = [] +): Promise<{ number: number; html_url: string } | null> { + const token = await getInstallationToken(appId, privateKey, owner, repo); + if (!token) return null; + + const payload: { title: string; body: string; labels?: string[] } = { title, body }; + if (labels.length > 0) payload.labels = labels; + + const response = await fetch( + `${GITHUB_API_BASE}/repos/${owner}/${repo}/issues`, + { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'Content-Type': 'application/json', + 'User-Agent': 'discord-github-worker', + }, + body: JSON.stringify(payload), + } + ); + + if (!response.ok) { + console.error('Failed to create issue:', await response.text()); + return null; + } + + return await response.json() as { number: number; html_url: string }; +} + +// GitHub webhook 서명 검증 +export async function verifyGitHubSignature( + secret: string, + signature: string, + payload: string +): Promise { + try { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ); + + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload)); + const expectedSig = 'sha256=' + Array.from(new Uint8Array(sig)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return signature === expectedSig; + } catch (error) { + console.error('GitHub signature verification failed:', error); + return false; + } +} diff --git a/discord-github-worker/src/utils/mapping.ts b/discord-github-worker/src/utils/mapping.ts new file mode 100644 index 0000000..ac95047 --- /dev/null +++ b/discord-github-worker/src/utils/mapping.ts @@ -0,0 +1,103 @@ +// KV 매핑 관리 +// issue:{owner}/{repo}/{issueNumber} → Discord 포스트 ID +// post:{postId} → {owner}/{repo}/{issueNumber} +// notion:{owner}/{repo}/{issueNumber} → Notion 페이지 ID + +export interface IssueMapping { + owner: string; + repo: string; + issueNumber: number; +} + +export async function getDiscordPostId( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number +): Promise { + const key = `issue:${owner}/${repo}/${issueNumber}`; + return await kv.get(key); +} + +export async function getIssueFromPost( + kv: KVNamespace, + postId: string +): Promise { + const key = `post:${postId}`; + const value = await kv.get(key); + if (!value) return null; + + const [owner, repo, issueNumber] = value.split('/'); + return { + owner, + repo: repo.replace(/\/\d+$/, ''), + issueNumber: parseInt(issueNumber, 10) + }; +} + +export async function createMapping( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number, + postId: string +): Promise { + const issueKey = `issue:${owner}/${repo}/${issueNumber}`; + const postKey = `post:${postId}`; + const issueValue = `${owner}/${repo}/${issueNumber}`; + + await Promise.all([ + kv.put(issueKey, postId), + kv.put(postKey, issueValue) + ]); +} + +export async function deleteMapping( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number, + postId: string +): Promise { + const issueKey = `issue:${owner}/${repo}/${issueNumber}`; + const postKey = `post:${postId}`; + + await Promise.all([ + kv.delete(issueKey), + kv.delete(postKey) + ]); +} + +// Notion 페이지 ID 조회 +export async function getNotionPageId( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number +): Promise { + const key = `notion:${owner}/${repo}/${issueNumber}`; + return await kv.get(key); +} + +// Notion 매핑 저장 +export async function createNotionMapping( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number, + notionPageId: string +): Promise { + const key = `notion:${owner}/${repo}/${issueNumber}`; + await kv.put(key, notionPageId); +} + +// Notion 매핑 삭제 +export async function deleteNotionMapping( + kv: KVNamespace, + owner: string, + repo: string, + issueNumber: number +): Promise { + const key = `notion:${owner}/${repo}/${issueNumber}`; + await kv.delete(key); +} diff --git a/discord-github-worker/src/utils/notion-api.ts b/discord-github-worker/src/utils/notion-api.ts new file mode 100644 index 0000000..468164a --- /dev/null +++ b/discord-github-worker/src/utils/notion-api.ts @@ -0,0 +1,163 @@ +// Notion API 호출 유틸리티 + +const NOTION_API_BASE = 'https://api.notion.com/v1'; +const NOTION_VERSION = '2022-06-28'; + +interface NotionPageProperties { + title: string; + issueNumber: number; + githubUrl: string; + state: 'open' | 'closed'; + author: string; + createdAt: string; + type?: 'issue' | 'pr'; +} + +async function notionFetch( + endpoint: string, + apiKey: string, + options: RequestInit = {} +): Promise { + const url = `${NOTION_API_BASE}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + 'Notion-Version': NOTION_VERSION, + ...options.headers, + }, + }); + return response; +} + +// Notion 데이터베이스에 페이지(이슈) 생성 +export async function createNotionPage( + apiKey: string, + databaseId: string, + properties: NotionPageProperties +): Promise<{ id: string } | null> { + // 상태 매핑: open → 📋 To Do, closed → ❌ 닫힘 + const statusName = properties.state === 'open' ? '📋 To Do' : '❌ 닫힘'; + // 타입 매핑 + const typeName = properties.type === 'pr' ? '📝 PR' : '✨ Feature'; + + const response = await notionFetch( + '/pages', + apiKey, + { + method: 'POST', + body: JSON.stringify({ + parent: { database_id: databaseId }, + properties: { + '프로젝트 이름': { + title: [ + { + text: { + content: properties.title, + }, + }, + ], + }, + '번호': { + number: properties.issueNumber, + }, + 'GitHub 링크': { + url: properties.githubUrl, + }, + '상태': { + multi_select: [{ name: statusName }], + }, + '타입': { + select: { name: typeName }, + }, + '생성일': { + date: { + start: properties.createdAt, + }, + }, + }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to create Notion page:', errorText); + return null; + } + + const data = await response.json() as { id: string }; + console.log(`Created Notion page: ${data.id}`); + return { id: data.id }; +} + +// Notion 페이지 상태 업데이트 +export async function updateNotionPageStatus( + apiKey: string, + pageId: string, + state: 'open' | 'closed' +): Promise { + const statusName = state === 'open' ? '📋 To Do' : '❌ 닫힘'; + + const response = await notionFetch( + `/pages/${pageId}`, + apiKey, + { + method: 'PATCH', + body: JSON.stringify({ + properties: { + '상태': { + multi_select: [{ name: statusName }], + }, + }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to update Notion page:', errorText); + return false; + } + + console.log(`Updated Notion page ${pageId} status to ${state}`); + return true; +} + +// Notion 페이지 제목 업데이트 (이슈 제목 변경 시) +export async function updateNotionPageTitle( + apiKey: string, + pageId: string, + title: string +): Promise { + const response = await notionFetch( + `/pages/${pageId}`, + apiKey, + { + method: 'PATCH', + body: JSON.stringify({ + properties: { + '프로젝트 이름': { + title: [ + { + text: { + content: title, + }, + }, + ], + }, + }, + }), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to update Notion page title:', errorText); + return false; + } + + console.log(`Updated Notion page ${pageId} title`); + return true; +} diff --git a/discord-github-worker/tsconfig.json b/discord-github-worker/tsconfig.json new file mode 100644 index 0000000..6d20693 --- /dev/null +++ b/discord-github-worker/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "node", + "lib": ["ES2021"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/discord-github-worker/wrangler.toml b/discord-github-worker/wrangler.toml new file mode 100644 index 0000000..d896b38 --- /dev/null +++ b/discord-github-worker/wrangler.toml @@ -0,0 +1,27 @@ +name = "discord-github-worker" +main = "src/index.ts" +compatibility_date = "2024-01-01" + +[observability] +enabled = true + +[observability.logs] +enabled = true +invocation_logs = true + +# KV 네임스페이스 바인딩 (wrangler kv namespace create MAPPING 후 ID 입력) +[[kv_namespaces]] +binding = "MAPPING" +id = "e6b091817bf34a4b95a000f40ac345dc" + +[vars] +GITHUB_APP_ID = "2724882" +DISCORD_FORUM_CHANNEL_ID = "1464865388885049434" +DISCORD_PR_CHANNEL_ID = "1464865388885049434" +# Secrets (wrangler secret put 명령으로 설정) +# DISCORD_PUBLIC_KEY +# DISCORD_BOT_TOKEN +# GITHUB_PRIVATE_KEY +# GITHUB_WEBHOOK_SECRET +# NOTION_API_TOKEN +# NOTION_DATA_SOURCE_ID From fa4f12a03a498966b38186c3275461ce642dbeb3 Mon Sep 17 00:00:00 2001 From: alphago2580 Date: Sun, 8 Feb 2026 16:09:23 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=EB=A0=88=EA=B1=B0?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Env 인터페이스 통합 (index.ts에서 단일 export) - (env as any) 타입 캐스팅 제거, GITHUB_OWNER/REPO 환경변수로 분리 - mapping.ts split 후 불필요한 regex 제거 - github-api.ts PEM 디버그 로그 제거 (보안) - GitHub webhook 서명 검증 timing-safe 비교로 변경 - 레거시 Python 파일 삭제 (main.py, requirements.txt, config.example.json) - README.md 간소화 + docs/README.md 아키텍처 문서 추가 Co-Authored-By: Claude Opus 4.6 --- discord-github-worker/README.md | 30 +---- discord-github-worker/config.example.json | 6 - discord-github-worker/docs/README.md | 118 ++++++++++++++++++ discord-github-worker/main.py | 25 ---- discord-github-worker/requirements.txt | 4 - discord-github-worker/src/discord.ts | 15 +-- discord-github-worker/src/github.ts | 13 +- discord-github-worker/src/index.ts | 6 + discord-github-worker/src/utils/github-api.ts | 38 ++---- discord-github-worker/src/utils/mapping.ts | 8 +- discord-github-worker/wrangler.toml | 2 + 11 files changed, 149 insertions(+), 116 deletions(-) delete mode 100644 discord-github-worker/config.example.json create mode 100644 discord-github-worker/docs/README.md delete mode 100644 discord-github-worker/main.py delete mode 100644 discord-github-worker/requirements.txt diff --git a/discord-github-worker/README.md b/discord-github-worker/README.md index 9f028c1..cdb3460 100644 --- a/discord-github-worker/README.md +++ b/discord-github-worker/README.md @@ -1,29 +1,5 @@ -# Discord-GitHub Issue Worker +# Discord-GitHub-Notion Worker -디스코드와 깃허브 이슈를 연동하는 워커입니다. +GitHub, Discord, Notion 3-way 동기화를 위한 Cloudflare Worker. -## 기능 - -- GitHub 이슈 생성/업데이트 시 Discord 알림 -- Discord 명령어로 GitHub 이슈 생성 -- 이슈 상태 변경 추적 - -## 설정 - -`config.json` 파일을 생성하고 다음 내용을 입력하세요: - -```json -{ - "discord_token": "YOUR_DISCORD_BOT_TOKEN", - "discord_channel_id": "YOUR_CHANNEL_ID", - "github_token": "YOUR_GITHUB_TOKEN", - "github_repo": "owner/repo" -} -``` - -## 실행 - -```bash -pip install -r requirements.txt -python main.py -``` +자세한 내용은 [docs/README.md](docs/README.md) 참고. diff --git a/discord-github-worker/config.example.json b/discord-github-worker/config.example.json deleted file mode 100644 index 4eb9633..0000000 --- a/discord-github-worker/config.example.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "discord_token": "YOUR_DISCORD_BOT_TOKEN", - "discord_channel_id": "YOUR_CHANNEL_ID", - "github_token": "YOUR_GITHUB_TOKEN", - "github_repo": "alphago2580/MESA" -} diff --git a/discord-github-worker/docs/README.md b/discord-github-worker/docs/README.md new file mode 100644 index 0000000..2a02707 --- /dev/null +++ b/discord-github-worker/docs/README.md @@ -0,0 +1,118 @@ +# Discord-GitHub-Notion Integration Worker + +Cloudflare Workers 기반 서버리스 애플리케이션. +GitHub 이슈, Discord 포럼 포스트, Notion 데이터베이스를 양방향으로 동기화한다. + +## Architecture + +``` +┌─────────────┐ POST /discord ┌──────────────────┐ GitHub API +│ Discord │ ─────────────────────▶│ │────────────────▶ Issues, Comments +│ Interaction │ │ │ +└─────────────┘ │ Cloudflare │ Notion API + │ Worker │────────────────▶ Database Pages +┌─────────────┐ POST /github │ │ +│ GitHub │ ─────────────────────▶│ │ Discord API +│ Webhook │ │ │────────────────▶ Forum Posts, Embeds +└─────────────┘ └────────┬─────────┘ + │ + KV Namespace + (양방향 매핑 저장) + issue ↔ post ↔ notion +``` + +## Features + +### GitHub → Discord +| 이벤트 | 동작 | +|--------|------| +| 이슈 생성 | 포럼 채널에 새 포스트 생성 | +| 이슈 댓글 | 연결된 포스트에 메시지 전송 | +| 이슈 close/reopen | 포스트에 상태 변경 알림 | +| PR opened/closed/merged | PR 채널에 Embed 알림 | + +### GitHub → Notion +| 이벤트 | 동작 | +|--------|------| +| 이슈 생성 | 데이터베이스에 페이지 추가 | +| 이슈 close/reopen | 페이지 상태 업데이트 | + +### Discord → GitHub +| 커맨드 | 동작 | +|--------|------| +| `/issue create` | Modal 폼으로 GitHub 이슈 생성 (라벨 지원) | +| `/comment ` | 연결된 GitHub 이슈에 댓글 전송 | +| `/sync` | 현재 포스트의 연결된 이슈 정보 확인 | + +## Tech Stack + +- **Runtime**: Cloudflare Workers +- **Language**: TypeScript +- **Storage**: Cloudflare KV (양방향 매핑) +- **Auth**: GitHub App (JWT + Installation Token), Discord Ed25519 서명 검증 + +## Project Structure + +``` +discord-github-worker/ +├── src/ +│ ├── index.ts # 라우터 (엔트리포인트) +│ ├── discord.ts # Discord Interaction 핸들러 +│ ├── github.ts # GitHub Webhook 핸들러 +│ └── utils/ +│ ├── discord-api.ts # Discord API 호출 +│ ├── github-api.ts # GitHub App 인증 + API 호출 +│ ├── notion-api.ts # Notion API 호출 +│ └── mapping.ts # KV 매핑 관리 +├── scripts/ +│ └── register-commands.js # 슬래시 커맨드 등록 +├── wrangler.toml # Cloudflare Workers 설정 +└── docs/ + └── README.md # 이 문서 +``` + +## KV Mapping Schema + +``` +issue:{owner}/{repo}/{number} → Discord Post ID +post:{postId} → {owner}/{repo}/{number} +notion:{owner}/{repo}/{number} → Notion Page ID +``` + +## Setup + +### 1. KV Namespace 생성 +```bash +wrangler kv namespace create MAPPING +# 출력된 ID를 wrangler.toml에 입력 +``` + +### 2. Secrets 설정 +```bash +wrangler secret put DISCORD_PUBLIC_KEY +wrangler secret put DISCORD_BOT_TOKEN +wrangler secret put GITHUB_PRIVATE_KEY +wrangler secret put GITHUB_WEBHOOK_SECRET +wrangler secret put NOTION_API_TOKEN # 선택 +wrangler secret put NOTION_DATA_SOURCE_ID # 선택 +``` + +### 3. 슬래시 커맨드 등록 +```bash +DISCORD_APP_ID=xxx DISCORD_BOT_TOKEN=xxx node scripts/register-commands.js +``` + +### 4. 배포 +```bash +npm run deploy +``` + +### 5. Webhook 연결 +- **Discord**: Bot Settings → Interactions Endpoint URL → `https://.workers.dev/discord` +- **GitHub**: Repo Settings → Webhooks → `https://.workers.dev/github` + - Events: Issues, Issue comments, Pull requests + +## Loop Prevention + +Discord→GitHub 댓글에 `commented on Discord:` 마커를 포함시키고, +GitHub→Discord 동기화 시 해당 마커가 있는 댓글은 무시하여 무한 루프를 방지한다. diff --git a/discord-github-worker/main.py b/discord-github-worker/main.py deleted file mode 100644 index 9e9d884..0000000 --- a/discord-github-worker/main.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Discord-GitHub Issue Worker -디스코드와 깃허브 이슈를 연동하는 워커 -""" - -import json -import discord -from discord.ext import commands -from github import Github - -# TODO: 구현 예정 - - -def load_config(): - with open("config.json", "r") as f: - return json.load(f) - - -def main(): - print("Discord-GitHub Issue Worker") - print("TODO: 구현 예정") - - -if __name__ == "__main__": - main() diff --git a/discord-github-worker/requirements.txt b/discord-github-worker/requirements.txt deleted file mode 100644 index 6fa2fe1..0000000 --- a/discord-github-worker/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -discord.py>=2.0.0 -PyGithub>=2.1.0 -python-dotenv>=1.0.0 -aiohttp>=3.9.0 diff --git a/discord-github-worker/src/discord.ts b/discord-github-worker/src/discord.ts index ce7c750..c8b2957 100644 --- a/discord-github-worker/src/discord.ts +++ b/discord-github-worker/src/discord.ts @@ -1,19 +1,10 @@ // Discord 이벤트 핸들러 +import type { Env } from './index'; import { verifyDiscordSignature } from './utils/discord-api'; import { addIssueComment, createIssue } from './utils/github-api'; import { getIssueFromPost } from './utils/mapping'; -interface Env { - MAPPING: KVNamespace; - DISCORD_PUBLIC_KEY: string; - DISCORD_BOT_TOKEN: string; - GITHUB_APP_ID: string; - GITHUB_PRIVATE_KEY: string; - GITHUB_OWNER?: string; - GITHUB_REPO?: string; -} - interface DiscordInteraction { type: number; token: string; @@ -348,8 +339,8 @@ async function handleModalSubmit( const user = interaction.member?.user || interaction.user; const username = user?.global_name || user?.username || 'Unknown'; - const owner = (env as any).GITHUB_OWNER || 'alphago2580'; - const repo = (env as any).GITHUB_REPO || 'MESA'; + const owner = env.GITHUB_OWNER; + const repo = env.GITHUB_REPO; const token = interaction.token; const appId = interaction.application_id; diff --git a/discord-github-worker/src/github.ts b/discord-github-worker/src/github.ts index 4b739db..5869f3c 100644 --- a/discord-github-worker/src/github.ts +++ b/discord-github-worker/src/github.ts @@ -1,5 +1,6 @@ // GitHub Webhook 핸들러 +import type { Env } from './index'; import { verifyGitHubSignature } from './utils/github-api'; import { createForumPost, @@ -9,18 +10,6 @@ import { import { getDiscordPostId, createMapping, getNotionPageId, createNotionMapping } from './utils/mapping'; import { createNotionPage, updateNotionPageStatus } from './utils/notion-api'; -interface Env { - MAPPING: KVNamespace; - DISCORD_BOT_TOKEN: string; - GITHUB_WEBHOOK_SECRET: string; - DISCORD_FORUM_CHANNEL_ID?: string; // 이슈용 포럼 채널 - DISCORD_PR_CHANNEL_ID?: string; // PR 알림 채널 - NOTION_API_KEY?: string; // Notion Integration 토큰 (레거시) - NOTION_API_TOKEN?: string; // Notion Integration 토큰 - NOTION_DATABASE_ID?: string; // Notion 데이터베이스 ID (vars) - NOTION_DATA_SOURCE_ID?: string; // Notion 데이터베이스 ID (secret) -} - interface GitHubUser { login: string; avatar_url: string; diff --git a/discord-github-worker/src/index.ts b/discord-github-worker/src/index.ts index 33f9662..6c8c0f0 100644 --- a/discord-github-worker/src/index.ts +++ b/discord-github-worker/src/index.ts @@ -10,8 +10,14 @@ export interface Env { GITHUB_APP_ID: string; GITHUB_PRIVATE_KEY: string; GITHUB_WEBHOOK_SECRET: string; + GITHUB_OWNER: string; + GITHUB_REPO: string; DISCORD_FORUM_CHANNEL_ID?: string; DISCORD_PR_CHANNEL_ID?: string; + NOTION_API_KEY?: string; + NOTION_API_TOKEN?: string; + NOTION_DATABASE_ID?: string; + NOTION_DATA_SOURCE_ID?: string; } export default { diff --git a/discord-github-worker/src/utils/github-api.ts b/discord-github-worker/src/utils/github-api.ts index e9fd0e8..8f8bae1 100644 --- a/discord-github-worker/src/utils/github-api.ts +++ b/discord-github-worker/src/utils/github-api.ts @@ -13,26 +13,12 @@ function base64urlEncode(data: Uint8Array | string): string { // PEM 형식의 private key 파싱 (PKCS#1 → PKCS#8 변환 포함) function pemToArrayBuffer(pem: string): ArrayBuffer { - // 앞뒤 공백 제거 let decodedPem = pem.trim(); - console.log('PEM input length:', decodedPem.length); - console.log('PEM starts with:', decodedPem.substring(0, 30)); - // base64로 인코딩된 PEM인 경우 먼저 디코딩 if (!decodedPem.includes('-----BEGIN')) { - try { - // 혹시 있을 수 있는 특수문자 제거 - decodedPem = decodedPem.replace(/[\r\n\s]/g, ''); - console.log('Cleaned PEM length:', decodedPem.length); - decodedPem = atob(decodedPem); - console.log('Decoded PEM length:', decodedPem.length); - console.log('Decoded PEM starts with:', decodedPem.substring(0, 30)); - } catch (e) { - console.error('Failed to decode base64 PEM:', e); - console.error('First 50 chars:', decodedPem.substring(0, 50)); - throw e; - } + decodedPem = decodedPem.replace(/[\r\n\s]/g, ''); + decodedPem = atob(decodedPem); } const isPkcs1 = decodedPem.includes('BEGIN RSA PRIVATE KEY'); @@ -51,15 +37,7 @@ function pemToArrayBuffer(pem: string): ArrayBuffer { paddedBase64 += '='.repeat(4 - remainder); } - console.log('Key base64 length:', base64.length, '-> padded:', paddedBase64.length); - - let binary: string; - try { - binary = atob(paddedBase64); - } catch (e) { - console.error('Failed to decode key base64:', e); - throw e; - } + const binary = atob(paddedBase64); const pkcs1Bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { pkcs1Bytes[i] = binary.charCodeAt(i); @@ -278,7 +256,15 @@ export async function verifyGitHubSignature( .map(b => b.toString(16).padStart(2, '0')) .join(''); - return signature === expectedSig; + // timing-safe 비교 + if (signature.length !== expectedSig.length) return false; + const a = encoder.encode(signature); + const b = encoder.encode(expectedSig); + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a[i] ^ b[i]; + } + return result === 0; } catch (error) { console.error('GitHub signature verification failed:', error); return false; diff --git a/discord-github-worker/src/utils/mapping.ts b/discord-github-worker/src/utils/mapping.ts index ac95047..653a21e 100644 --- a/discord-github-worker/src/utils/mapping.ts +++ b/discord-github-worker/src/utils/mapping.ts @@ -27,11 +27,11 @@ export async function getIssueFromPost( const value = await kv.get(key); if (!value) return null; - const [owner, repo, issueNumber] = value.split('/'); + const parts = value.split('/'); return { - owner, - repo: repo.replace(/\/\d+$/, ''), - issueNumber: parseInt(issueNumber, 10) + owner: parts[0], + repo: parts[1], + issueNumber: parseInt(parts[2], 10) }; } diff --git a/discord-github-worker/wrangler.toml b/discord-github-worker/wrangler.toml index d896b38..696cda5 100644 --- a/discord-github-worker/wrangler.toml +++ b/discord-github-worker/wrangler.toml @@ -16,6 +16,8 @@ id = "e6b091817bf34a4b95a000f40ac345dc" [vars] GITHUB_APP_ID = "2724882" +GITHUB_OWNER = "alphago2580" +GITHUB_REPO = "MESA" DISCORD_FORUM_CHANNEL_ID = "1464865388885049434" DISCORD_PR_CHANNEL_ID = "1464865388885049434" # Secrets (wrangler secret put 명령으로 설정) From 462c8c578098de8e2ecec5c4238c7d4e3c6c2115 Mon Sep 17 00:00:00 2001 From: alphago2580 Date: Sun, 8 Feb 2026 16:23:40 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20CLAUDE.md=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EB=AC=B8=EC=84=9C=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GITHUB_OWNER, GITHUB_REPO 환경변수 추가 - Secrets 섹션 추가 (NOTION_API_TOKEN, NOTION_DATA_SOURCE_ID 등) Co-Authored-By: Claude Opus 4.6 --- discord-github-worker/CLAUDE.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/discord-github-worker/CLAUDE.md b/discord-github-worker/CLAUDE.md index e04aff1..78707c1 100644 --- a/discord-github-worker/CLAUDE.md +++ b/discord-github-worker/CLAUDE.md @@ -44,10 +44,20 @@ wrangler kv namespace create MAPPING ### Environment Variables `wrangler.toml`의 `[vars]` 섹션에서 설정: - `GITHUB_APP_ID`: GitHub App ID +- `GITHUB_OWNER`: GitHub 레포지토리 소유자 +- `GITHUB_REPO`: GitHub 레포지토리 이름 - `DISCORD_FORUM_CHANNEL_ID`: 이슈 동기화용 Discord 포럼 채널 ID - `DISCORD_PR_CHANNEL_ID`: PR 알림용 Discord 채널 ID - `NOTION_DATABASE_ID`: Notion 데이터베이스 ID (선택사항) +Secrets (`wrangler secret put`으로 설정): +- `DISCORD_PUBLIC_KEY`: Discord 앱 공개 키 +- `DISCORD_BOT_TOKEN`: Discord 봇 토큰 +- `GITHUB_PRIVATE_KEY`: GitHub App 비공개 키 +- `GITHUB_WEBHOOK_SECRET`: GitHub Webhook 시크릿 +- `NOTION_API_TOKEN`: Notion Integration 토큰 (선택사항) +- `NOTION_DATA_SOURCE_ID`: Notion 데이터베이스 ID (선택사항) + ## Architecture ### Request Flow