commit 9498095d0c80896ed5e560a126a23c1ca873920a Author: DaeHan-Ji Date: Wed Jun 10 17:53:39 2026 +0900 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e88b98a --- /dev/null +++ b/.env.example @@ -0,0 +1,39 @@ +# --- Slack --- +# Bot User OAuth Token (xoxb-...). Scopes 필요: chat:write, users:read.email, im:write +SLACK_BOT_TOKEN=xoxb-your-token + +# 담당자 이메일을 Slack에서 못 찾았을 때 보낼 fallback 채널(선택). 비우면 그냥 skip. +DEFAULT_SLACK_CHANNEL= + +# --- Gitea --- +# Gitea webhook 설정 시 입력한 Secret. HMAC-SHA256 서명 검증에 사용. +GITEA_WEBHOOK_SECRET= + +# --- Notion --- +# Notion webhook subscription 검증 토큰 (서명 검증용) +NOTION_VERIFICATION_TOKEN= +# Notion Internal Integration Token (ntn_... / secret_...). 페이지/유저 조회에 사용. +NOTION_API_TOKEN= +# 담당자가 들어있는 Notion People 속성 이름 +NOTION_ASSIGNEE_PROPERTY=담당자 + +# --- App --- +# 수동 유저 매핑 저장 파일 (관리 페이지에서 편집) +MAPPING_FILE=data/mappings.json +# 리슨 주소 +ADDR=:8000 + +# --- 로깅 (환경별) --- +# 환경은 실행 명령어 -env 플래그로 분기: `go run . -env dev` | `-env prod` +# dev → text 포맷 + debug 레벨, 콘솔(IDE)만 출력 +# prod → json 포맷 + info 레벨, logs/app.log 파일로 추출 (+ stdout) +# (APP_ENV는 -env 미지정 시의 fallback일 뿐. 보통 안 써도 됨) +# APP_ENV=dev +# 아래 셋은 비우면 환경에 따라 자동 결정됨. 명시하면 오버라이드. +# LOG_LEVEL=debug # debug | info | warn | error +# LOG_FORMAT=text # text | json +# LOG_FILE=logs/app.log # 경로 지정 시 stdout + 파일 동시 기록. dev에서 비우면 콘솔만, +# # prod에서 비우면 자동으로 logs/app.log 사용 + +# (선택) 저사양 환경에서 메모리 상한 가이드 — Go 런타임이 이 아래로 유지하려 함 +# GOMEMLIMIT=200MiB diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f45f0e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Secrets — 모든 .env 변형은 제외, 예시 템플릿만 추적 +.env +.env.* +!.env.example + +# Runtime data (유저 매핑은 환경마다 다름) +/data/ + +# 로그 파일 (production은 logs/app.log로 추출) +/logs/ + +# Build output +/slack-notifier +/out/ +/.idea \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4d40160 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# slack-notifier + +Gitea/Notion webhook 알림을 담당자에게 Slack 개인 DM으로 중계하는 **Go(Gin)** 서버. +워크스페이스 규칙은 상위 [../CLAUDE.md](../CLAUDE.md) 참고. + +## 스택 / 실행 + +- **Go 1.26 (Gin)** — 버전은 mise로 관리(`mise use go@1.26`) +- 관리 UI: 표준 `html/template` + HTMX (Node 빌드 불필요), 템플릿은 `go:embed`로 바이너리에 포함 +- 실행: `go run .` (기본 `:8000`) · 테스트: `go test ./...` · 빌드: `go build -o slack-notifier .` +- 배포: `docker compose up --build` (distroless 경량 이미지, 실행 위치 미정 — 외부 접근 HTTPS 필요) + +## 구조 + +``` +main.go # 조립: config → mapping/slack/notion → server.Run +internal/ + config/ config.go # env 설정 (.env, godotenv) + 환경/로그 기본값 + logging/ logging.go # log/slog 환경별 설정 + Gin 액세스로그 미들웨어 + security/ security.go # HMAC-SHA256 서명 검증 (Gitea/Notion 공용) + slack/ slack.go # users.lookupByEmail + chat.postMessage(DM), email 캐시 + notify/ notify.go # Notification 모델 + Block Kit 헬퍼 + mapping/ mapping.go # 수동 매핑 스토어 (data/mappings.json, thread-safe) + gitea/ gitea.go(+test)# Gitea 이벤트 → 담당자 이메일/로그인 + 메시지 + notion/ notion.go # Notion 이벤트 → 페이지 People 속성 → 이메일 + server/ server.go # Gin 엔진, 라우트, deliver() + webhooks.go # POST /webhooks/{gitea,notion} + admin.go # /admin/mappings (GET/POST/DELETE, HTMX) + templates/ # mappings.html (embed) +data/mappings.json # 런타임 매핑 데이터 (gitignore) +``` + +## 핵심 동작 + +- 담당자 해석 순서: **수동 매핑(이메일→, 로그인→)** 우선, 없으면 원래 이메일 그대로 → Slack 조회. + - 못 찾으면 `DEFAULT_SLACK_CHANNEL` fallback, 그것도 없으면 skip(로그만). +- 모든 webhook은 raw body 기준 HMAC-SHA256 검증. secret 미설정 시 검증 skip(개발용, WARN 로그). +- Notion은 최초 구독 시 받은 `verification_token`을 로그에서 확인해 `.env`에 넣어야 정상 검증됨. +- 관리 페이지에서 추가/삭제한 매핑은 즉시 `data/mappings.json`에 저장. +- 환경은 **실행 명령어 `-env` 플래그로 분기**(`go run . -env prod`). 값은 `dev`|`prod`(긴 형태 development/production도 정규화됨). 미지정 시 `APP_ENV` → `dev` 순 fallback. + - 환경별 `.env` 로드: `.env.{env}.local` → `.env.{env}` → `.env`(앞이 우선). 예: `.env.dev`, `.env.prod`. +- 로깅은 `log/slog` 표준 라이브러리. 환경별로 결정: + - `dev` → text 포맷 + debug 레벨 + Gin debug 모드, **콘솔(IDE)만 출력** + - `prod` → JSON 포맷 + info 레벨 + Gin release 모드, **`logs/app.log` 파일로 추출**(+ stdout) + - `LOG_LEVEL`/`LOG_FORMAT`/`LOG_FILE`로 개별 오버라이드. 코드에선 `slog.Info/Warn/Error` 사용(표준 `log` 금지). + - `LOG_FILE` 지정 시 부모 디렉터리는 자동 생성, stdout과 파일에 동시 기록. dev 기본값은 빈 값(콘솔만). + - 파일 회전은 앱이 하지 않음 — Docker/journald/logrotate에 위임(저사양·의존성 최소화). + +## 작업 시 주의 + +- 이벤트별 "담당자" 로직은 제품 정책에 가까움. 새 이벤트/대상 규칙은 `internal/gitea`·`internal/notion`의 + `BuildNotifications`에 추가하고, 가능하면 테스트(`*_test.go`)도 같이. +- 메시지는 한국어 + Block Kit(`notify.SimpleBlocks`). +- 비밀값(`SLACK_BOT_TOKEN`, secret, Notion 토큰)은 `.env`로만 관리, 커밋 금지(`.gitignore` 확인). +- 저사양 타겟이므로 무거운 의존성 추가 자제. 라우터는 Gin, 그 외는 표준 라이브러리 위주. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc4a5f4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- build stage --- +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# 정적 바이너리 (CGO 끔), 디버그 심볼 제거로 크기 축소. 템플릿은 go:embed로 포함됨. +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/slack-notifier . + +# --- run stage (distroless static, ~2MB 베이스) --- +FROM gcr.io/distroless/static-debian12 +WORKDIR /app +COPY --from=build /out/slack-notifier /app/slack-notifier +ENV ADDR=:8000 +ENV MAPPING_FILE=/app/data/mappings.json +ENV LOG_FILE=/app/logs/app.log +EXPOSE 8000 +ENTRYPOINT ["/app/slack-notifier"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4b854ae --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +# 환경은 "실행 명령어"로 분기한다 (-env 플래그). 환경변수 APP_ENV에 의존하지 않음. +.PHONY: run-dev run-prod build build-linux test vet fmt + +# --- 로컬 실행 (명령어로 환경 선택) --- +run-dev: ## 개발 모드 실행 (text 로그, debug, 콘솔만) + go run . -env dev + +run-prod: ## 운영 모드 실행 (JSON 로그, info, logs/app.log) + go run . -env prod + +# --- 빌드 --- +build: ## 현재 OS용 바이너리 + go build -ldflags="-s -w" -o slack-notifier . + +# EC2 배포용 정적 바이너리. 기본 arm64(t4g/Graviton). x86_64 인스턴스면 ARCH=amd64. +ARCH ?= arm64 +build-linux: ## EC2용 리눅스 정적 바이너리 (ARCH=arm64|amd64) + GOOS=linux GOARCH=$(ARCH) CGO_ENABLED=0 go build -ldflags="-s -w" -o slack-notifier . + @echo "built: slack-notifier (linux/$(ARCH))" + +# --- 검증 --- +test: + go test ./... +vet: + go vet ./... +fmt: + gofmt -w . diff --git a/README.md b/README.md new file mode 100644 index 0000000..7be0c98 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# slack-notifier + +Gitea webhook · Notion webhook 알림을 받아 **담당자에게 Slack 개인 DM**으로 보내는 중계 서버. + +- 담당자 매핑: 이메일 자동 매칭(Gitea/Notion 이메일 → Slack `users.lookupByEmail`) + **수동 매핑 보강**(관리 페이지) +- 스택: **Go + Gin** (저사양 환경 대응, 단일 정적 바이너리), 관리 UI는 html/template + HTMX + +``` +[Gitea] ──webhook──┐ + ├─▶ slack-notifier ─▶ (매핑 해석) ─▶ users.lookupByEmail ─▶ chat.postMessage(DM) +[Notion] ─webhook──┘ (서명검증) 자동+수동 이메일→Slack ID +``` + +## 빠른 시작 (로컬) + +Go는 [mise](https://mise.jdx.dev)로 관리합니다 (`mise use go@1.26`). + +```bash +cd slack-notifier +cp .env.example .env # 값 채우기 +go run . # 기본 :8000 +``` + +- 헬스체크: `curl localhost:8000/health` +- 관리 페이지: 브라우저로 `http://localhost:8000/admin/mappings` +- 테스트: `go test ./...` +- 빌드: `go build -o slack-notifier .` + +Docker: + +```bash +docker compose up --build # distroless 기반 경량 이미지 +``` + +## 엔드포인트 + +| 메서드 | 경로 | 설명 | +|---|---|---| +| GET | `/health` | 헬스체크 | +| POST | `/webhooks/gitea` | Gitea webhook 수신 | +| POST | `/webhooks/notion` | Notion webhook 수신 (+ 최초 검증 핸드셰이크) | +| GET | `/admin/mappings` | 유저 매핑 관리 페이지 | +| POST | `/admin/mappings` | 매핑 추가 (HTMX) | +| DELETE | `/admin/mappings/:source` | 매핑 삭제 (HTMX) | + +## 담당자 매핑 + +1. **자동(이메일)**: Gitea/Notion 이벤트의 이메일을 그대로 Slack에서 조회. +2. **수동(오버라이드)**: `/admin/mappings`에서 `소스(이메일 또는 로그인) → Slack 이메일`을 등록. + 시스템 간 이메일이 다르거나 자동 조회가 안 될 때 보강. `data/mappings.json`에 저장됨. +3. 그래도 못 찾으면 `DEFAULT_SLACK_CHANNEL`로 fallback(설정 시), 없으면 skip. + +## Slack 앱 준비 + +1. https://api.slack.com/apps 에서 앱 생성 → **Bot Token Scopes**: `chat:write`, `users:read.email`, `im:write` +2. 워크스페이스에 설치 후 **Bot User OAuth Token**(`xoxb-...`)을 `SLACK_BOT_TOKEN`에 설정 + +## Gitea 설정 + +저장소/조직 → Settings → Webhooks → Add Webhook (Gitea) + +- **Target URL**: `https://<배포주소>/webhooks/gitea` +- **Secret**: `GITEA_WEBHOOK_SECRET`와 동일하게 입력 (HMAC-SHA256 서명 검증) + +처리하는 이벤트 → 담당자: + +| 이벤트 | 알림 대상 | +|--------|-----------| +| PR `assigned` / `opened` / `reopened` | 담당자(assignees) | +| PR `review_requested` | 리뷰어 | +| PR `closed`(merged) | PR 작성자 | +| PR review 등록 | PR 작성자 | +| 이슈 `assigned` | 담당자 | +| 이슈/PR 댓글 | 담당자 + 작성자 (댓글 단 본인 제외) | + +> 그 외 이벤트(예: push)는 기본 무시. 필요하면 `internal/gitea/gitea.go`에 핸들러를 추가하세요. + +## Notion 설정 + +1. **Integration** 생성 → `NOTION_API_TOKEN` 설정, 대상 DB/페이지에 integration 연결(공유) +2. Notion **Webhook subscription** 생성 → endpoint `https://<배포주소>/webhooks/notion` +3. 최초 구독 시 Notion이 `verification_token`을 POST → 서버 로그에 출력됨 → `NOTION_VERIFICATION_TOKEN`에 넣고 재시작 +4. 담당자는 페이지의 **People 속성**(이름은 `NOTION_ASSIGNEE_PROPERTY`, 기본 `담당자`)에서 읽어 이메일로 매핑 + +## 저사양 배포 메모 + +- 단일 정적 바이너리(distroless 이미지 ~10MB대), idle 메모리 수십 MB 수준. +- 메모리가 빠듯하면 `GOMEMLIMIT=200MiB` 등으로 상한을 줄 수 있음(README/`.env.example` 참고). +- webhook 수신을 위해 외부 접근 가능한 HTTPS 엔드포인트 필요. 로컬 테스트는 `ngrok http 8000` 같은 터널 사용. diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..38cc05d --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,14 @@ +# slack-notifier 앞단 HTTPS 리버스 프록시 (Caddy). +# 웹훅은 공개 HTTPS가 필수 — Caddy가 Let's Encrypt 인증서를 자동 발급/갱신한다. +# +# 1) your-domain.com 을 실제 도메인으로 교체 (EC2 퍼블릭 IP에 A 레코드 연결) +# 2) EC2 보안그룹 인바운드 80, 443 열기 (ACME 검증 + 웹훅 수신) +# 3) caddy 설치 후: sudo cp deploy/Caddyfile /etc/caddy/Caddyfile && sudo systemctl restart caddy +# +# 웹훅 등록 주소: +# https://your-domain.com/webhooks/gitea +# https://your-domain.com/webhooks/notion + +your-domain.com { + reverse_proxy localhost:8000 +} diff --git a/deploy/run.sh b/deploy/run.sh new file mode 100755 index 0000000..7e651ab --- /dev/null +++ b/deploy/run.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# +# slack-notifier 백그라운드 실행 스크립트 (systemd 없이 간단 운영용). +# +# ./deploy/run.sh start [dev|prod] # 백그라운드 시작 (기본 prod) +# ./deploy/run.sh stop # 종료 +# ./deploy/run.sh restart [dev|prod] # 재시작 +# ./deploy/run.sh status # 상태 확인 +# ./deploy/run.sh logs # 구조적 로그(app.log) 실시간 +# +# 바이너리(slack-notifier)와 .env.{env} 는 프로젝트 루트에 있어야 한다. +set -euo pipefail + +# 스크립트 위치(deploy/) 기준으로 프로젝트 루트 결정 +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$APP_DIR" + +BIN="./slack-notifier" +ENV="${2:-prod}" # 기본 prod (dev 도 가능) +PID_FILE="$APP_DIR/run/slack-notifier.pid" +CONSOLE_LOG="$APP_DIR/logs/console.log" # stdout/stderr (앱 시작 전 출력·패닉 포함) + +mkdir -p "$APP_DIR/run" "$APP_DIR/logs" + +is_running() { + [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null +} + +start() { + if is_running; then + echo "이미 실행 중 (pid $(cat "$PID_FILE"))" + exit 0 + fi + if [[ ! -x "$BIN" ]]; then + echo "바이너리가 없거나 실행 권한이 없음: $BIN" + echo " → make build-linux 후 이 디렉터리에 두고 chmod +x 하세요" + exit 1 + fi + nohup "$BIN" -env "$ENV" >>"$CONSOLE_LOG" 2>&1 & + echo $! >"$PID_FILE" + sleep 1 + if is_running; then + echo "시작됨 (pid $(cat "$PID_FILE"), env=$ENV)" + else + echo "시작 실패 — 마지막 로그:" + tail -n 20 "$CONSOLE_LOG" + rm -f "$PID_FILE" + exit 1 + fi +} + +stop() { + if ! is_running; then + echo "실행 중이 아님" + rm -f "$PID_FILE" + return 0 + fi + local pid + pid="$(cat "$PID_FILE")" + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 10); do + is_running || break + sleep 0.5 + done + if is_running; then + echo "강제 종료(kill -9)" + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" + echo "종료됨" +} + +case "${1:-}" in +start) start ;; +stop) stop ;; +restart) + stop + start + ;; +status) + if is_running; then + echo "running (pid $(cat "$PID_FILE"))" + else + echo "stopped" + fi + ;; +logs) tail -f "$APP_DIR/logs/app.log" ;; +*) + echo "사용법: $0 {start|stop|restart|status|logs} [dev|prod]" + exit 1 + ;; +esac diff --git a/deploy/slack-notifier.service b/deploy/slack-notifier.service new file mode 100644 index 0000000..bc51b21 --- /dev/null +++ b/deploy/slack-notifier.service @@ -0,0 +1,23 @@ +[Unit] +Description=slack-notifier (Gitea/Notion -> Slack DM) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/slack-notifier +# 저사양 보호: Go 런타임 메모리 상한 (인스턴스에 맞게 조정) +Environment=GOMEMLIMIT=180MiB +# 환경은 실행 명령어로 분기: -env prod -> JSON 로그 + logs/app.log + Gin release +ExecStart=/opt/slack-notifier/slack-notifier -env prod +Restart=always +RestartSec=3 +# cgroup 메모리 하드 리밋 (OOM 보호) +MemoryMax=200M +# 보안 하드닝 +NoNewPrivileges=true +ProtectSystem=full +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f3704ea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + slack-notifier: + build: . + command: ["-env", "prod"] # 환경은 실행 명령어로 분기 (JSON 로그 + logs/app.log) + env_file: + - .env.prod # 비밀값/설정 (ADDR은 넣지 말 것 — 컨테이너는 :8000 고정) + ports: + - "6000:8000" # host:container — 리버스 프록시는 host:6000 → 컨테이너 8000 + volumes: + - ./data:/app/data # 유저 매핑(data/mappings.json) 영속화 + - ./logs:/app/logs # production 로그(logs/app.log) 영속화 + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f07bd3d --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module git/palnet/slack-notifier + +go 1.26.4 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/joho/godotenv v1.5.1 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9e60278 --- /dev/null +++ b/go.sum @@ -0,0 +1,91 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..23c7ad0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "strings" + + "github.com/joho/godotenv" +) + +// Config holds all runtime settings, loaded from environment (.env optional). +type Config struct { + // Slack + SlackBotToken string + DefaultSlackChannel string // fallback channel when a recipient email can't be resolved + + // Gitea + GiteaWebhookSecret string + + // Notion + NotionVerificationToken string + NotionAPIToken string + NotionAssigneeProperty string + + // App + MappingFile string + Addr string + + // Logging + Env string // development | production + LogLevel string // debug | info | warn | error + LogFormat string // text | json + LogFile string // optional file path; empty = stdout only +} + +// Load reads configuration for the given environment and loads its .env files. +// +// The environment is decided by the run command (the -env flag passed as +// envOverride). If empty, it falls back to the APP_ENV variable, then +// "development". This keeps env selection command-driven, not ambient. +// +// Precedence: an earlier-loaded file wins (godotenv.Load never overrides an +// already-set variable), and missing files are ignored: +// +// .env.{env}.local # 개인 비밀값 (git 제외, 선택) +// .env.{env} # 환경별 설정 (.env.dev / .env.prod) +// .env # 공통 기본값 (fallback) +func Load(envOverride string) Config { + env := envOverride + if env == "" { + env = getenv("APP_ENV", "dev") + } + env = normalizeEnv(env) + + _ = godotenv.Load(".env." + env + ".local") + _ = godotenv.Load(".env." + env) + _ = godotenv.Load(".env") + + return Config{ + SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), + DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), + GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), + NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), + NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), + NotionAssigneeProperty: getenv("NOTION_ASSIGNEE_PROPERTY", "담당자"), + MappingFile: getenv("MAPPING_FILE", "data/mappings.json"), + Addr: getenv("ADDR", ":8000"), + + Env: env, + LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)), + LogFormat: getenv("LOG_FORMAT", defaultLogFormat(env)), + LogFile: getenv("LOG_FILE", defaultLogFile(env)), + } +} + +// normalizeEnv canonicalizes the env name to the short form "dev" or "prod". +// Long forms (development/production) and unknown values map sensibly. +func normalizeEnv(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "prod", "production": + return "prod" + default: + return "dev" + } +} + +// IsProduction reports whether the app runs in the production environment. +func (c Config) IsProduction() bool { return c.Env == "prod" } + +func defaultLogLevel(env string) string { + if env == "prod" { + return "info" + } + return "debug" +} + +func defaultLogFormat(env string) string { + if env == "prod" { + return "json" + } + return "text" +} + +// defaultLogFile decides where logs are written when LOG_FILE is unset: +// - dev → "" (콘솔/IDE 출력만) +// - prod → 파일로 추출 (logs/app.log) +func defaultLogFile(env string) string { + if env == "prod" { + return "logs/app.log" + } + return "" +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..0349f2d --- /dev/null +++ b/internal/gitea/gitea.go @@ -0,0 +1,255 @@ +// Package gitea maps Gitea webhook events to the DMs that should be sent. +package gitea + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + "git/palnet/slack-notifier/internal/notify" +) + +type user struct { + Login string `json:"login"` + Username string `json:"username"` + Email string `json:"email"` +} + +func (u *user) login() string { + if u == nil { + return "" + } + if u.Login != "" { + return u.Login + } + return u.Username +} + +func (u *user) email() string { + if u == nil { + return "" + } + return u.Email +} + +type pullRequest struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Merged bool `json:"merged"` + User *user `json:"user"` + Assignee *user `json:"assignee"` + Assignees []user `json:"assignees"` +} + +type issue struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + User *user `json:"user"` + Assignee *user `json:"assignee"` + Assignees []user `json:"assignees"` +} + +type comment struct { + Body string `json:"body"` + HTMLURL string `json:"html_url"` +} + +type review struct { + Content string `json:"content"` + Type string `json:"type"` +} + +type payload struct { + Action string `json:"action"` + Number int `json:"number"` + Repository struct { + FullName string `json:"full_name"` + } `json:"repository"` + Sender *user `json:"sender"` + PullRequest *pullRequest `json:"pull_request"` + Issue *issue `json:"issue"` + Comment *comment `json:"comment"` + Review *review `json:"review"` + RequestedReviewer *user `json:"requested_reviewer"` +} + +func assigneesOf(single *user, list []user) []user { + if len(list) > 0 { + return list + } + if single != nil { + return []user{*single} + } + return nil +} + +// BuildNotifications turns a Gitea webhook (event header + raw JSON) into DMs. +func BuildNotifications(event string, body []byte) ([]notify.Notification, error) { + var p payload + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + + repo := p.Repository.FullName + sender := p.Sender.login() + + var notes []notify.Notification + switch event { + case "pull_request": + notes = handlePullRequest(&p, repo, sender) + case "issues": + notes = handleIssues(&p, repo, sender) + case "issue_comment": + notes = handleIssueComment(&p, repo, sender) + case "pull_request_review": + notes = handlePullRequestReview(&p, repo, sender) + default: + slog.Info("gitea event not handled — ignored", "event", event) + return nil, nil + } + + return dedupe(notes), nil +} + +func handlePullRequest(p *payload, repo, sender string) []notify.Notification { + pr := p.PullRequest + if pr == nil { + return nil + } + tag := fmt.Sprintf("%s PR #%d", repo, pr.Number) + body := fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title) + var notes []notify.Notification + + switch p.Action { + case "assigned": + for _, u := range assigneesOf(pr.Assignee, pr.Assignees) { + notes = append(notes, note(u.email(), u.login(), + fmt.Sprintf("[%s] 담당자로 지정되었습니다", tag), + "📌 PR 담당자 지정 — "+tag, body, "by "+sender)) + } + case "review_requested": + r := p.RequestedReviewer + notes = append(notes, note(r.email(), r.login(), + fmt.Sprintf("[%s] 리뷰 요청을 받았습니다", tag), + "👀 리뷰 요청 — "+tag, body, "by "+sender)) + case "opened", "reopened": + for _, u := range assigneesOf(pr.Assignee, pr.Assignees) { + notes = append(notes, note(u.email(), u.login(), + fmt.Sprintf("[%s] 담당 PR이 %s 되었습니다", tag, p.Action), + fmt.Sprintf("🔀 PR %s — %s", p.Action, tag), body, "by "+sender)) + } + case "closed": + if pr.Merged { + notes = append(notes, note(pr.User.email(), pr.User.login(), + fmt.Sprintf("[%s] 작성한 PR이 머지되었습니다", tag), + "✅ PR 머지됨 — "+tag, body, "by "+sender)) + } + } + return notes +} + +func handleIssues(p *payload, repo, sender string) []notify.Notification { + iss := p.Issue + if iss == nil || p.Action != "assigned" { + return nil + } + tag := fmt.Sprintf("%s 이슈 #%d", repo, iss.Number) + body := fmt.Sprintf("<%s|%s>", iss.HTMLURL, iss.Title) + var notes []notify.Notification + for _, u := range assigneesOf(iss.Assignee, iss.Assignees) { + notes = append(notes, note(u.email(), u.login(), + fmt.Sprintf("[%s] 담당자로 지정되었습니다", tag), + "📌 이슈 담당자 지정 — "+tag, body, "by "+sender)) + } + return notes +} + +func handleIssueComment(p *payload, repo, sender string) []notify.Notification { + iss := p.Issue + if iss == nil { + return nil + } + tag := fmt.Sprintf("%s #%d", repo, iss.Number) + url := iss.HTMLURL + if p.Comment != nil && p.Comment.HTMLURL != "" { + url = p.Comment.HTMLURL + } + snippet := "" + if p.Comment != nil { + snippet = strings.TrimSpace(p.Comment.Body) + if len([]rune(snippet)) > 200 { + snippet = string([]rune(snippet)[:200]) + "…" + } + } + body := fmt.Sprintf("<%s|%s>\n> %s", url, iss.Title, snippet) + + // 담당자 + 작성자에게 알림. 단, 댓글 작성자 본인은 제외. + recipients := assigneesOf(iss.Assignee, iss.Assignees) + if iss.User != nil { + recipients = append(recipients, *iss.User) + } + var notes []notify.Notification + for _, u := range recipients { + if u.email() == "" || u.login() == sender { + continue + } + notes = append(notes, note(u.email(), u.login(), + fmt.Sprintf("[%s] 새 댓글이 달렸습니다", tag), + "💬 새 댓글 — "+tag, body, "by "+sender)) + } + return notes +} + +func handlePullRequestReview(p *payload, repo, sender string) []notify.Notification { + pr := p.PullRequest + if pr == nil { + return nil + } + tag := fmt.Sprintf("%s PR #%d", repo, pr.Number) + state := "리뷰" + if p.Review != nil && p.Review.Type != "" { + state = strings.TrimPrefix(p.Review.Type, "pull_request_review_") + } + body := fmt.Sprintf("<%s|%s>", pr.HTMLURL, pr.Title) + if p.Review != nil && strings.TrimSpace(p.Review.Content) != "" { + c := strings.TrimSpace(p.Review.Content) + if len([]rune(c)) > 200 { + c = string([]rune(c)[:200]) + } + body += "\n> " + c + } + + // 리뷰 결과는 PR 작성자에게 알림. + return []notify.Notification{note(pr.User.email(), pr.User.login(), + fmt.Sprintf("[%s] 리뷰가 등록되었습니다 (%s)", tag, state), + "📝 리뷰 "+state+" — "+tag, body, "by "+sender)} +} + +func note(email, login, text, title, body, context string) notify.Notification { + return notify.Notification{ + Email: email, + Login: login, + Text: text, + Blocks: notify.SimpleBlocks(title, body, context), + } +} + +func dedupe(in []notify.Notification) []notify.Notification { + seen := make(map[string]struct{}) + out := make([]notify.Notification, 0, len(in)) + for _, n := range in { + if n.Email == "" && n.Login == "" { + continue + } + key := n.Email + "|" + n.Login + "|" + n.Text + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, n) + } + return out +} diff --git a/internal/gitea/gitea_test.go b/internal/gitea/gitea_test.go new file mode 100644 index 0000000..a820241 --- /dev/null +++ b/internal/gitea/gitea_test.go @@ -0,0 +1,62 @@ +package gitea + +import "testing" + +func TestPullRequestAssigned(t *testing.T) { + body := []byte(`{ + "action": "assigned", + "repository": {"full_name": "team/repo"}, + "sender": {"login": "alice"}, + "pull_request": { + "number": 7, "title": "Add feature", "html_url": "http://g/7", + "user": {"login": "alice", "email": "alice@x.com"}, + "assignees": [{"login": "bob", "email": "bob@x.com"}] + } + }`) + notes, err := BuildNotifications("pull_request", body) + if err != nil { + t.Fatal(err) + } + if len(notes) != 1 || notes[0].Email != "bob@x.com" { + t.Fatalf("expected one note to bob@x.com, got %+v", notes) + } +} + +func TestReviewRequested(t *testing.T) { + body := []byte(`{ + "action": "review_requested", + "repository": {"full_name": "team/repo"}, + "sender": {"login": "alice"}, + "pull_request": {"number": 8, "title": "Fix", "html_url": "u"}, + "requested_reviewer": {"login": "carol", "email": "carol@x.com"} + }`) + notes, _ := BuildNotifications("pull_request", body) + if len(notes) != 1 || notes[0].Email != "carol@x.com" { + t.Fatalf("expected note to carol@x.com, got %+v", notes) + } +} + +func TestIssueCommentExcludesCommenter(t *testing.T) { + body := []byte(`{ + "action": "created", + "repository": {"full_name": "team/repo"}, + "sender": {"login": "alice"}, + "issue": { + "number": 3, "title": "Bug", "html_url": "u", + "user": {"login": "alice", "email": "alice@x.com"}, + "assignees": [{"login": "bob", "email": "bob@x.com"}] + }, + "comment": {"body": "hi", "html_url": "u#c"} + }`) + notes, _ := BuildNotifications("issue_comment", body) + if len(notes) != 1 || notes[0].Email != "bob@x.com" { + t.Fatalf("expected only bob@x.com (alice is commenter), got %+v", notes) + } +} + +func TestUnhandledEvent(t *testing.T) { + notes, _ := BuildNotifications("push", []byte(`{"repository":{"full_name":"t/r"}}`)) + if len(notes) != 0 { + t.Fatalf("expected no notes for push, got %+v", notes) + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..2cd0557 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,94 @@ +// Package logging configures the application's structured logger (log/slog) +// based on the environment: human-readable text in development, JSON in production. +package logging + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "git/palnet/slack-notifier/internal/config" +) + +// Setup builds the slog logger from config and installs it as the default +// (so package-level slog.Info/Warn/Error calls everywhere use this config). +func Setup(cfg config.Config) *slog.Logger { + w := resolveWriter(cfg) + + opts := &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)} + + var h slog.Handler + if strings.EqualFold(cfg.LogFormat, "json") { + h = slog.NewJSONHandler(w, opts) + } else { + h = slog.NewTextHandler(w, opts) + } + + logger := slog.New(h).With("service", "slack-notifier", "env", cfg.Env) + slog.SetDefault(logger) + return logger +} + +// resolveWriter returns stdout (development), or stdout+file when LOG_FILE is set +// (production defaults to logs/app.log). Keeping stdout means Docker/journald still +// capture logs while the file gives a directly tail-able copy. +func resolveWriter(cfg config.Config) io.Writer { + if cfg.LogFile == "" { + return os.Stdout + } + if dir := filepath.Dir(cfg.LogFile); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + slog.Warn("cannot create log dir, using stdout only", "dir", dir, "err", err) + return os.Stdout + } + } + f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + // Can't open the file yet — log later via default; fall back to stdout. + slog.Warn("cannot open log file, using stdout only", "file", cfg.LogFile, "err", err) + return os.Stdout + } + return io.MultiWriter(os.Stdout, f) +} + +func parseLevel(s string) slog.Level { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +// GinMiddleware logs each HTTP request through slog, replacing gin.Logger() +// so access logs share the same env-based format. +func GinMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + c.Next() + + level := slog.LevelInfo + if c.Writer.Status() >= 500 { + level = slog.LevelError + } else if c.Writer.Status() >= 400 { + level = slog.LevelWarn + } + + slog.LogAttrs(c.Request.Context(), level, "http_request", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.Int("status", c.Writer.Status()), + slog.Int64("latency_ms", time.Since(start).Milliseconds()), + slog.String("client_ip", c.ClientIP()), + ) + } +} diff --git a/internal/mapping/mapping.go b/internal/mapping/mapping.go new file mode 100644 index 0000000..5fb364d --- /dev/null +++ b/internal/mapping/mapping.go @@ -0,0 +1,113 @@ +// Package mapping holds the manual source->Slack-email overrides that the admin +// page manages. It supplements automatic email matching for when the Gitea/Notion +// email differs from the Slack email (or isn't present). +package mapping + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" +) + +// Entry is one override row, as shown in the admin UI and stored on disk. +type Entry struct { + Source string `json:"source"` // gitea/notion email OR login/username + SlackEmail string `json:"slack_email"` // email to look up in Slack +} + +// Store is a thread-safe, file-backed map of Source -> SlackEmail. +type Store struct { + mu sync.RWMutex + path string + m map[string]string +} + +// New loads the store from path (an empty store if the file is absent). +func New(path string) (*Store, error) { + s := &Store{path: path, m: make(map[string]string)} + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return s, nil + } + if err != nil { + return nil, err + } + + var entries []Entry + if len(data) > 0 { + if err := json.Unmarshal(data, &entries); err != nil { + return nil, err + } + } + for _, e := range entries { + s.m[e.Source] = e.SlackEmail + } + return s, nil +} + +// Resolve returns the Slack email to use for a recipient. It prefers an override +// keyed by email, then by login, otherwise falls back to the original email. +func (s *Store) Resolve(email, login string) string { + s.mu.RLock() + defer s.mu.RUnlock() + if email != "" { + if v, ok := s.m[email]; ok { + return v + } + } + if login != "" { + if v, ok := s.m[login]; ok { + return v + } + } + return email +} + +// List returns all entries sorted by source (for stable UI rendering). +func (s *Store) List() []Entry { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]Entry, 0, len(s.m)) + for k, v := range s.m { + out = append(out, Entry{Source: k, SlackEmail: v}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Source < out[j].Source }) + return out +} + +// Add inserts/updates an override and persists. +func (s *Store) Add(source, slackEmail string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.m[source] = slackEmail + return s.save() +} + +// Delete removes an override and persists. +func (s *Store) Delete(source string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, source) + return s.save() +} + +// save writes the store to disk. Caller must hold the write lock. +func (s *Store) save() error { + entries := make([]Entry, 0, len(s.m)) + for k, v := range s.m { + entries = append(entries, Entry{Source: k, SlackEmail: v}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Source < entries[j].Source }) + + data, err := json.MarshalIndent(entries, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + return os.WriteFile(s.path, data, 0o644) +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..ad90b5b --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,27 @@ +package notify + +// Block is a single Slack Block Kit element (rendered to JSON for chat.postMessage). +type Block = map[string]any + +// Notification is one DM to deliver: who (Email / Login for mapping) gets what. +type Notification struct { + Email string // best-known recipient email (from Gitea/Notion) + Login string // source username/login, used as a secondary mapping key + Text string // plain-text fallback (notifications, accessibility) + Blocks []Block +} + +// SimpleBlocks builds a message: bold title, body section, optional context line. +func SimpleBlocks(title, body, context string) []Block { + blocks := []Block{ + {"type": "section", "text": Block{"type": "mrkdwn", "text": "*" + title + "*"}}, + {"type": "section", "text": Block{"type": "mrkdwn", "text": body}}, + } + if context != "" { + blocks = append(blocks, Block{ + "type": "context", + "elements": []Block{{"type": "mrkdwn", "text": context}}, + }) + } + return blocks +} diff --git a/internal/notion/notion.go b/internal/notion/notion.go new file mode 100644 index 0000000..e2b8d6c --- /dev/null +++ b/internal/notion/notion.go @@ -0,0 +1,174 @@ +// Package notion maps Notion webhook events to DMs for a page's assignees. +package notion + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "git/palnet/slack-notifier/internal/notify" +) + +const ( + apiBase = "https://api.notion.com/v1" + version = "2022-06-28" +) + +// Client calls the Notion API to resolve a page's assignees into emails. +type Client struct { + token string + assigneeProperty string + http *http.Client +} + +func New(token, assigneeProperty string) *Client { + return &Client{ + token: token, + assigneeProperty: assigneeProperty, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +type event struct { + Type string `json:"type"` + Entity struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"entity"` +} + +type property struct { + Type string `json:"type"` + Title []struct { + PlainText string `json:"plain_text"` + } `json:"title"` + People []struct { + ID string `json:"id"` + } `json:"people"` +} + +type page struct { + URL string `json:"url"` + Properties map[string]property `json:"properties"` +} + +// BuildNotifications parses a Notion event and returns DMs for the page assignees. +func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.Notification, error) { + var ev event + if err := json.Unmarshal(body, &ev); err != nil { + return nil, err + } + if ev.Entity.Type != "page" || ev.Entity.ID == "" { + slog.Info("notion event entity is not a page — ignored", "type", ev.Entity.Type) + return nil, nil + } + + var pg page + if err := c.get(ctx, "pages/"+ev.Entity.ID, &pg); err != nil { + slog.Warn("notion get page failed", "err", err) + return nil, nil + } + + title := pageTitle(&pg) + body2 := title + if pg.URL != "" { + body2 = fmt.Sprintf("<%s|%s>", pg.URL, title) + } + evType := ev.Type + if evType == "" { + evType = "변경" + } + + var notes []notify.Notification + for _, id := range c.assigneeIDs(&pg) { + email := c.resolveEmail(ctx, id) + if email == "" { + slog.Info("notion user has no accessible email — skipped", "user_id", id) + continue + } + notes = append(notes, notify.Notification{ + Email: email, + Text: fmt.Sprintf("[Notion] 담당 페이지가 업데이트되었습니다 (%s)", evType), + Blocks: notify.SimpleBlocks( + "🗒️ Notion 업데이트 — "+evType, body2, ""), + }) + } + return notes, nil +} + +func pageTitle(pg *page) string { + for _, prop := range pg.Properties { + if prop.Type == "title" { + s := "" + for _, t := range prop.Title { + s += t.PlainText + } + if s != "" { + return s + } + } + } + return "(제목 없음)" +} + +func (c *Client) assigneeIDs(pg *page) []string { + prop, ok := pg.Properties[c.assigneeProperty] + if !ok || prop.Type != "people" { + slog.Info("notion page has no people property", "property", c.assigneeProperty) + return nil + } + ids := make([]string, 0, len(prop.People)) + for _, p := range prop.People { + if p.ID != "" { + ids = append(ids, p.ID) + } + } + return ids +} + +func (c *Client) resolveEmail(ctx context.Context, userID string) string { + var u struct { + Type string `json:"type"` + Person struct { + Email string `json:"email"` + } `json:"person"` + } + if err := c.get(ctx, "users/"+userID, &u); err != nil { + return "" + } + if u.Type != "person" { + return "" + } + return u.Person.Email +} + +func (c *Client) get(ctx context.Context, path string, out any) error { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/"+path, nil) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Notion-Version", version) + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("notion %s: status %d: %s", path, resp.StatusCode, truncate(data, 200)) + } + return json.Unmarshal(data, out) +} + +func truncate(b []byte, n int) string { + if len(b) > n { + return string(b[:n]) + } + return string(b) +} diff --git a/internal/security/security.go b/internal/security/security.go new file mode 100644 index 0000000..30b8803 --- /dev/null +++ b/internal/security/security.go @@ -0,0 +1,34 @@ +package security + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "log/slog" + "strings" +) + +// VerifyHMACSHA256 checks a hex HMAC-SHA256 signature over the raw body. +// +// If no secret is configured we skip verification (dev convenience) but log a +// warning, so it is never silently insecure where a secret IS set. +func VerifyHMACSHA256(secret string, body []byte, signature string) bool { + if secret == "" { + slog.Warn("signature secret not configured — skipping verification") + return true + } + if signature == "" { + return false + } + + signature = strings.TrimSpace(signature) + // Some providers prefix the digest, e.g. "sha256=abc...". + if strings.HasPrefix(strings.ToLower(signature), "sha256=") { + signature = signature[len("sha256="):] + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..adf66b4 --- /dev/null +++ b/internal/server/admin.go @@ -0,0 +1,39 @@ +package server + +import ( + "log/slog" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +func (s *Server) adminMappings(c *gin.Context) { + c.HTML(http.StatusOK, "mappings.html", gin.H{"Mappings": s.mappings.List()}) +} + +func (s *Server) adminAddMapping(c *gin.Context) { + source := strings.TrimSpace(c.PostForm("source")) + slackEmail := strings.TrimSpace(c.PostForm("slack_email")) + if source == "" || slackEmail == "" { + c.String(http.StatusBadRequest, "source and slack_email are required") + return + } + if err := s.mappings.Add(source, slackEmail); err != nil { + slog.Error("add mapping failed", "source", source, "err", err) + c.String(http.StatusInternalServerError, "failed to save") + return + } + // Return the refreshed table partial (HTMX swaps it in). + c.HTML(http.StatusOK, "table", gin.H{"Mappings": s.mappings.List()}) +} + +func (s *Server) adminDeleteMapping(c *gin.Context) { + source := c.Param("source") + if err := s.mappings.Delete(source); err != nil { + slog.Error("delete mapping failed", "source", source, "err", err) + c.String(http.StatusInternalServerError, "failed to delete") + return + } + c.HTML(http.StatusOK, "table", gin.H{"Mappings": s.mappings.List()}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..91caad7 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,75 @@ +package server + +import ( + "context" + "embed" + "html/template" + "net/http" + + "github.com/gin-gonic/gin" + + "git/palnet/slack-notifier/internal/config" + "git/palnet/slack-notifier/internal/logging" + "git/palnet/slack-notifier/internal/mapping" + "git/palnet/slack-notifier/internal/notify" + "git/palnet/slack-notifier/internal/notion" + "git/palnet/slack-notifier/internal/slack" +) + +//go:embed templates/*.html +var templatesFS embed.FS + +// Server wires together config, the Slack client, the mapping store and Notion client. +type Server struct { + cfg config.Config + slack *slack.Client + mappings *mapping.Store + notion *notion.Client +} + +func New(cfg config.Config, sl *slack.Client, m *mapping.Store, nt *notion.Client) *Server { + return &Server{cfg: cfg, slack: sl, mappings: m, notion: nt} +} + +// Router builds the Gin engine with all routes registered. +func (s *Server) Router() *gin.Engine { + if s.cfg.IsProduction() { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + r.Use(logging.GinMiddleware(), gin.Recovery()) + _ = r.SetTrustedProxies(nil) // we don't sit behind a trusted proxy by default + + tmpl := template.Must(template.ParseFS(templatesFS, "templates/*.html")) + r.SetHTMLTemplate(tmpl) + + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + r.POST("/webhooks/gitea", s.handleGitea) + r.POST("/webhooks/notion", s.handleNotion) + + admin := r.Group("/admin") + { + admin.GET("/mappings", s.adminMappings) + admin.POST("/mappings", s.adminAddMapping) + admin.DELETE("/mappings/:source", s.adminDeleteMapping) + } + + return r +} + +// deliver resolves each notification's recipient via the mapping store and DMs them. +// Returns how many were sent successfully. +func (s *Server) deliver(ctx context.Context, notes []notify.Notification) int { + sent := 0 + for _, n := range notes { + email := s.mappings.Resolve(n.Email, n.Login) + if s.slack.SendDM(ctx, email, n.Text, n.Blocks, s.cfg.DefaultSlackChannel) { + sent++ + } + } + return sent +} diff --git a/internal/server/templates/mappings.html b/internal/server/templates/mappings.html new file mode 100644 index 0000000..769ae2f --- /dev/null +++ b/internal/server/templates/mappings.html @@ -0,0 +1,65 @@ + + + + + + slack-notifier · 유저 매핑 관리 + + + + +

유저 매핑 관리

+

+ 소스(Gitea/Notion 이메일 또는 로그인명) → Slack 이메일 오버라이드.
+ 이메일 자동 매칭이 실패하거나, 시스템 간 이메일이 다를 때 사용합니다. +

+ +
+ + + +
+ + {{template "table" .}} + + + +{{define "table"}} + + + + + + {{range .Mappings}} + + + + + + {{else}} + + {{end}} + +
소스 (이메일/로그인)Slack 이메일
{{.Source}}{{.SlackEmail}} + +
등록된 매핑이 없습니다.
+{{end}} diff --git a/internal/server/webhooks.go b/internal/server/webhooks.go new file mode 100644 index 0000000..96b0524 --- /dev/null +++ b/internal/server/webhooks.go @@ -0,0 +1,73 @@ +package server + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + + "git/palnet/slack-notifier/internal/gitea" + "git/palnet/slack-notifier/internal/security" +) + +func (s *Server) handleGitea(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"}) + return + } + + if !security.VerifyHMACSHA256(s.cfg.GiteaWebhookSecret, raw, c.GetHeader("X-Gitea-Signature")) { + slog.Warn("gitea signature verification failed") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) + return + } + + event := c.GetHeader("X-Gitea-Event") + notes, err := gitea.BuildNotifications(event, raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + sent := s.deliver(c.Request.Context(), notes) + c.JSON(http.StatusOK, gin.H{"event": event, "matched": len(notes), "sent": sent}) +} + +func (s *Server) handleNotion(c *gin.Context) { + raw, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot read body"}) + return + } + + // 1) Subscription verification handshake: Notion POSTs a verification_token once. + // Capture it from logs and paste into NOTION_VERIFICATION_TOKEN. + var probe struct { + VerificationToken string `json:"verification_token"` + } + _ = json.Unmarshal(raw, &probe) + if probe.VerificationToken != "" { + slog.Warn("notion verification_token received — set this in NOTION_VERIFICATION_TOKEN", "verification_token", probe.VerificationToken) + c.JSON(http.StatusOK, gin.H{"verification_token": probe.VerificationToken}) + return + } + + // 2) Normal events: verify signature against the verification token. + if !security.VerifyHMACSHA256(s.cfg.NotionVerificationToken, raw, c.GetHeader("X-Notion-Signature")) { + slog.Warn("notion signature verification failed") + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) + return + } + + notes, err := s.notion.BuildNotifications(c.Request.Context(), raw) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + sent := s.deliver(c.Request.Context(), notes) + c.JSON(http.StatusOK, gin.H{"matched": len(notes), "sent": sent}) +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go new file mode 100644 index 0000000..6375b01 --- /dev/null +++ b/internal/slack/slack.go @@ -0,0 +1,138 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "sync" + "time" + + "git/palnet/slack-notifier/internal/notify" +) + +const apiBase = "https://slack.com/api" + +// Client is a minimal Slack Web API client: resolve users by email and DM them. +type Client struct { + token string + http *http.Client + + mu sync.Mutex + cache map[string]string // email -> user id ("" = looked up, not found) +} + +func New(token string) *Client { + return &Client{ + token: token, + http: &http.Client{Timeout: 10 * time.Second}, + cache: make(map[string]string), + } +} + +func (c *Client) LookupUserIDByEmail(ctx context.Context, email string) (string, bool) { + if email == "" { + return "", false + } + + c.mu.Lock() + if id, ok := c.cache[email]; ok { + c.mu.Unlock() + return id, id != "" + } + c.mu.Unlock() + + endpoint := apiBase + "/users.lookupByEmail?" + url.Values{"email": {email}}.Encode() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + req.Header.Set("Authorization", "Bearer "+c.token) + + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + User struct { + ID string `json:"id"` + } `json:"user"` + } + if err := c.do(req, &out); err != nil { + slog.Warn("slack lookup failed", "email", email, "err", err) + return "", false + } + + id := "" + if out.OK { + id = out.User.ID + } else { + slog.Warn("slack user lookup failed", "email", email, "error", out.Error) + } + + c.mu.Lock() + c.cache[email] = id + c.mu.Unlock() + return id, id != "" +} + +func (c *Client) PostMessage(ctx context.Context, channel, text string, blocks []notify.Block) bool { + payload := map[string]any{"channel": channel} + if text != "" { + payload["text"] = text + } + if len(blocks) > 0 { + payload["blocks"] = blocks + } + body, _ := json.Marshal(payload) + + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/chat.postMessage", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + var out struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + if err := c.do(req, &out); err != nil { + slog.Error("slack postMessage failed", "channel", channel, "err", err) + return false + } + if !out.OK { + slog.Error("slack postMessage failed", "channel", channel, "error", out.Error) + return false + } + return true +} + +// SendDM resolves email -> Slack user and DMs them. Falls back to fallbackChannel +// if the email can't be resolved; if there's no fallback either, it is skipped. +func (c *Client) SendDM(ctx context.Context, email, text string, blocks []notify.Block, fallbackChannel string) bool { + if id, ok := c.LookupUserIDByEmail(ctx, email); ok { + return c.PostMessage(ctx, id, text, blocks) + } + + if fallbackChannel != "" { + prefix := "(담당자 미지정) " + if email != "" { + prefix = fmt.Sprintf("(담당자 %s 매핑 실패) ", email) + } + slog.Info("email unresolved — routing to fallback channel", "email", email, "channel", fallbackChannel) + return c.PostMessage(ctx, fallbackChannel, prefix+text, blocks) + } + + slog.Info("no Slack recipient and no fallback channel — skipped", "email", email) + return false +} + +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + return json.Unmarshal(data, out) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0189d1f --- /dev/null +++ b/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "flag" + "log/slog" + "os" + + "git/palnet/slack-notifier/internal/config" + "git/palnet/slack-notifier/internal/logging" + "git/palnet/slack-notifier/internal/mapping" + "git/palnet/slack-notifier/internal/notion" + "git/palnet/slack-notifier/internal/server" + "git/palnet/slack-notifier/internal/slack" +) + +func main() { + // 실행 환경은 명령어 플래그로 분기한다 (예: `slack-notifier -env prod`). + // 미지정 시 APP_ENV 환경변수 → 그래도 없으면 dev. + envFlag := flag.String("env", "", "실행 환경: dev | prod") + flag.Parse() + + cfg := config.Load(*envFlag) + logging.Setup(cfg) + + store, err := mapping.New(cfg.MappingFile) + if err != nil { + slog.Error("failed to load mapping store", "file", cfg.MappingFile, "err", err) + os.Exit(1) + } + + slackClient := slack.New(cfg.SlackBotToken) + notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperty) + + srv := server.New(cfg, slackClient, store, notionClient) + + slog.Info("slack-notifier starting", "addr", cfg.Addr, "log_level", cfg.LogLevel, "log_format", cfg.LogFormat) + if err := srv.Router().Run(cfg.Addr); err != nil { + slog.Error("server stopped", "err", err) + os.Exit(1) + } +}