DaeHan-Ji 2 weeks ago
commit
9498095d0c
  1. 39
      .env.example
  2. 15
      .gitignore
  3. 55
      CLAUDE.md
  4. 18
      Dockerfile
  5. 27
      Makefile
  6. 89
      README.md
  7. 14
      deploy/Caddyfile
  8. 92
      deploy/run.sh
  9. 23
      deploy/slack-notifier.service
  10. 12
      docker-compose.yml
  11. 40
      go.mod
  12. 91
      go.sum
  13. 118
      internal/config/config.go
  14. 255
      internal/gitea/gitea.go
  15. 62
      internal/gitea/gitea_test.go
  16. 94
      internal/logging/logging.go
  17. 113
      internal/mapping/mapping.go
  18. 27
      internal/notify/notify.go
  19. 174
      internal/notion/notion.go
  20. 34
      internal/security/security.go
  21. 39
      internal/server/admin.go
  22. 75
      internal/server/server.go
  23. 65
      internal/server/templates/mappings.html
  24. 73
      internal/server/webhooks.go
  25. 138
      internal/slack/slack.go
  26. 41
      main.go

39
.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

15
.gitignore vendored

@ -0,0 +1,15 @@
# Secrets — 모든 .env 변형은 제외, 예시 템플릿만 추적
.env
.env.*
!.env.example
# Runtime data (유저 매핑은 환경마다 다름)
/data/
# 로그 파일 (production은 logs/app.log로 추출)
/logs/
# Build output
/slack-notifier
/out/
/.idea

55
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, 그 외는 표준 라이브러리 위주.

18
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"]

27
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 .

89
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` 같은 터널 사용.

14
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
}

92
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

23
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

12
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

40
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
)

91
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=

118
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
}

255
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
}

62
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)
}
}

94
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()),
)
}
}

113
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)
}

27
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
}

174
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)
}

34
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))
}

39
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()})
}

75
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
}

65
internal/server/templates/mappings.html

@ -0,0 +1,65 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>slack-notifier · 유저 매핑 관리</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 760px; margin: 40px auto; padding: 0 16px; color: #222; }
h1 { font-size: 1.4rem; }
p.desc { color: #666; font-size: .9rem; }
table { border-collapse: collapse; width: 100%; margin-top: 8px; }
th, td { border: 1px solid #e2e2e2; padding: 8px 10px; text-align: left; font-size: .92rem; }
th { background: #fafafa; }
.row { display: flex; gap: 8px; margin: 16px 0; flex-wrap: wrap; }
input { padding: 7px 9px; border: 1px solid #ccc; border-radius: 6px; flex: 1; min-width: 180px; }
button { padding: 7px 14px; border: 0; border-radius: 6px; background: #4a154b; color: #fff; cursor: pointer; }
button.del { background: #b00020; padding: 4px 10px; font-size: .85rem; }
</style>
</head>
<body>
<h1>유저 매핑 관리</h1>
<p class="desc">
소스(Gitea/Notion <b>이메일</b> 또는 <b>로그인명</b>) → <b>Slack 이메일</b> 오버라이드.<br>
이메일 자동 매칭이 실패하거나, 시스템 간 이메일이 다를 때 사용합니다.
</p>
<form class="row"
hx-post="/admin/mappings"
hx-target="#mapping-table"
hx-swap="outerHTML"
hx-on::after-request="if(event.detail.successful) this.reset()">
<input name="source" placeholder="gitea/notion 이메일 또는 로그인" required>
<input name="slack_email" type="email" placeholder="slack 이메일" required>
<button type="submit">추가</button>
</form>
{{template "table" .}}
</body>
</html>
{{define "table"}}
<table id="mapping-table">
<thead>
<tr><th>소스 (이메일/로그인)</th><th>Slack 이메일</th><th></th></tr>
</thead>
<tbody>
{{range .Mappings}}
<tr>
<td>{{.Source}}</td>
<td>{{.SlackEmail}}</td>
<td>
<button class="del"
hx-delete="/admin/mappings/{{.Source}}"
hx-target="#mapping-table"
hx-swap="outerHTML"
hx-confirm="'{{.Source}}' 매핑을 삭제할까요?">삭제</button>
</td>
</tr>
{{else}}
<tr><td colspan="3" style="color:#888;text-align:center;">등록된 매핑이 없습니다.</td></tr>
{{end}}
</tbody>
</table>
{{end}}

73
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})
}

138
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)
}

41
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)
}
}
Loading…
Cancel
Save