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