commit
9498095d0c
26 changed files with 1823 additions and 0 deletions
@ -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 |
||||
@ -0,0 +1,15 @@
|
||||
# Secrets — 모든 .env 변형은 제외, 예시 템플릿만 추적 |
||||
.env |
||||
.env.* |
||||
!.env.example |
||||
|
||||
# Runtime data (유저 매핑은 환경마다 다름) |
||||
/data/ |
||||
|
||||
# 로그 파일 (production은 logs/app.log로 추출) |
||||
/logs/ |
||||
|
||||
# Build output |
||||
/slack-notifier |
||||
/out/ |
||||
/.idea |
||||
@ -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, 그 외는 표준 라이브러리 위주. |
||||
@ -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"] |
||||
@ -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 .
|
||||
@ -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` 같은 터널 사용. |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
@ -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 |
||||
) |
||||
@ -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= |
||||
@ -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 |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
@ -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()), |
||||
) |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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)) |
||||
} |
||||
@ -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()}) |
||||
} |
||||
@ -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 |
||||
} |
||||
@ -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}} |
||||
@ -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}) |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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…
Reference in new issue