From c405c5a0e228b1fb5b1df59c1c0afccd8ba4c34a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dhji=28=EC=A7=80=EB=8C=80=ED=95=9C=29?= Date: Mon, 15 Jun 2026 11:35:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Notion=20=EC=95=8C=EB=A6=BC=20=EA=B0=95?= =?UTF-8?q?=ED=99=94=20+=20HTTPS(Caddy)=20=EB=B0=B0=ED=8F=AC=20=EA=B5=AC?= =?UTF-8?q?=EC=84=B1=20-=20Notion=20DM=EC=97=90=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EC=9A=94=EC=95=BD=20=EC=B6=94=EA=B0=80(?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20=EB=B3=80=EA=B2=BD=3D=EC=9D=B4=EB=A6=84+?= =?UTF-8?q?=EC=83=88=20=EA=B0=92,=20=EB=B3=B8=EB=AC=B8=3D=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=20=ED=85=8D=EC=8A=A4=ED=8A=B8,=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=3D=EB=B3=B8=EB=AC=B8,=20=EC=83=9D=EC=84=B1/=EC=82=AD=EC=A0=9C/?= =?UTF-8?q?=EC=9D=B4=EB=8F=99/=EB=B3=B5=EC=9B=90=20=EB=9D=BC=EB=B2=A8,=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9E=90=20=ED=91=9C=EC=8B=9C)=20-=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=C2=B7=EB=8C=93=EA=B8=80=EC=9D=98=20@?= =?UTF-8?q?=EB=A9=98=EC=85=98=20=EB=8C=80=EC=83=81=EB=8F=84=20=EC=88=98?= =?UTF-8?q?=EC=8B=A0=EC=9E=90=EC=97=90=20=ED=8F=AC=ED=95=A8(=EC=9E=91?= =?UTF-8?q?=EC=84=B1=EC=9E=90=20=EB=B3=B8=EC=9D=B8=20=EC=A0=9C=EC=99=B8)?= =?UTF-8?q?=20-=20=EC=88=98=EC=8B=A0=20People=20=EC=86=8D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A4=EC=A4=91=ED=99=94:=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=EC=9E=90/=EC=B2=98=EB=A6=AC=EC=9E=90/=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EC=9D=B8=EC=9B=90(=EC=BD=A4=EB=A7=88=20=EA=B5=AC=EB=B6=84,=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0)=20-=20Caddy=20?= =?UTF-8?q?=EB=A6=AC=EB=B2=84=EC=8A=A4=20=ED=94=84=EB=A1=9D=EC=8B=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1:=20443+9998=20=EB=8F=99=EC=8B=9C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=99,=20=ED=8F=AC=ED=8A=B8=2080=20=EC=97=86=EC=9D=B4=20TLS?= =?UTF-8?q?-ALPN-01=EB=A1=9C=20=EC=9D=B8=EC=A6=9D=EC=84=9C=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89(Notion=EC=9D=80=20=EB=B9=84=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EA=B1=B0=EB=B6=80=20=E2=86=92=20443=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9)=20-=20notion=5Ftest.go:=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81/=EB=A9=98=EC=85=98=20=EC=B6=94=EC=B6=9C/?= =?UTF-8?q?=EC=86=8D=EC=84=B1=20dedup=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- README.md | 2 +- deploy/Caddyfile | 30 ++- deploy/nginx-slack-notifier.conf | 39 +++ docker-compose.yml | 2 +- internal/config/config.go | 40 ++- internal/notion/notion.go | 450 +++++++++++++++++++++++++++---- internal/notion/notion_test.go | 145 ++++++++++ main.go | 2 +- 9 files changed, 635 insertions(+), 79 deletions(-) create mode 100644 deploy/nginx-slack-notifier.conf create mode 100644 internal/notion/notion_test.go diff --git a/.env.example b/.env.example index e1c353a..de82ede 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,8 @@ GITEA_BROADCAST=off NOTION_VERIFICATION_TOKEN= # Notion Internal Integration Token (ntn_... / secret_...). 페이지/유저 조회에 사용. NOTION_API_TOKEN= -# 담당자가 들어있는 Notion People 속성 이름 -NOTION_ASSIGNEE_PROPERTY=담당자 +# 알림 대상이 들어있는 Notion People 속성 이름들 (콤마 구분, 모두 수신자에 포함) +NOTION_ASSIGNEE_PROPERTY=담당자,처리자,참조인원 # --- App --- # 수동 유저 매핑 저장 파일 (관리 페이지에서 편집) diff --git a/README.md b/README.md index 7be0c98..e09bdd8 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ docker compose up --build # distroless 기반 경량 이미지 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`, 기본 `담당자`)에서 읽어 이메일로 매핑 +4. 알림 대상은 페이지의 **People 속성들**(`NOTION_ASSIGNEE_PROPERTY`, 콤마 구분, 기본 `담당자,처리자,참조인원`)에서 읽어 이메일로 매핑. 여러 속성의 인원을 합쳐(중복 제거) 모두에게 발송. 본문/댓글에서 **@멘션된 사람**도 수신자에 추가됨(작성자 본인 제외). ## 저사양 배포 메모 diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 38cc05d..8ed490c 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,14 +1,28 @@ # slack-notifier 앞단 HTTPS 리버스 프록시 (Caddy). -# 웹훅은 공개 HTTPS가 필수 — Caddy가 Let's Encrypt 인증서를 자동 발급/갱신한다. +# https://gitea.palntour.com(:443) → http://localhost:9999 (Go 앱) +# https://gitea.palntour.com:9998 → http://localhost:9999 (Go 앱) # -# 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 +# 두 포트에서 동시에 서빙한다: +# - 443 : 표준 HTTPS. Notion 등 비표준 포트를 거부하는 웹훅 제공자용. (Notion은 이 주소로 등록) +# - 9998: 기존 Gitea 웹훅용 (그대로 유지). +# +# 포트 80은 다른 앱이 점유 중 → HTTP-01(80 필수) 대신 TLS-ALPN-01(443)로 인증서 발급/갱신. +# - auto_https disable_redirects: 80 점유 앱과 충돌하지 않도록 HTTP→HTTPS 리다이렉트 비활성. +# +# 사전 준비 +# 1) gitea.palntour.com 의 DNS A 레코드가 이 서버 IP를 가리킬 것 +# 2) EC2 보안그룹 인바운드를 0.0.0.0/0 으로: 443(Notion+인증서) + 9998(Gitea) ※ 80 불필요 +# 3) caddy: sudo cp deploy/Caddyfile /etc/caddy/Caddyfile && sudo systemctl reload caddy # # 웹훅 등록 주소: -# https://your-domain.com/webhooks/gitea -# https://your-domain.com/webhooks/notion +# Gitea : https://gitea.palntour.com:9998/webhooks/gitea +# Notion: https://gitea.palntour.com/webhooks/notion (← 포트 생략 = 443) + +{ + # 80을 쓰는 다른 앱과 충돌 방지 (HTTP→HTTPS 자동 리다이렉트 끔) + auto_https disable_redirects +} -your-domain.com { - reverse_proxy localhost:8000 +gitea.palntour.com:443, gitea.palntour.com:9998 { + reverse_proxy localhost:9999 } diff --git a/deploy/nginx-slack-notifier.conf b/deploy/nginx-slack-notifier.conf new file mode 100644 index 0000000..74eca98 --- /dev/null +++ b/deploy/nginx-slack-notifier.conf @@ -0,0 +1,39 @@ +# slack-notifier 앞단 HTTPS 리버스 프록시 (nginx). +# gitea.palntour.com:9998 (HTTPS) → 127.0.0.1:9999 (Go 앱) +# +# 배치: sudo cp deploy/nginx-slack-notifier.conf /etc/nginx/conf.d/slack-notifier.conf +# sudo nginx -t && sudo systemctl reload nginx +# +# 사전 준비 +# 1) EC2 보안그룹 인바운드: 80(인증서 발급용), 9998(웹훅 수신) 열기 +# 2) Let's Encrypt 인증서 발급: sudo certbot certonly --standalone -d gitea.palntour.com +# 3) (Amazon Linux/SELinux) 아래 두 줄: +# sudo semanage port -a -t http_port_t -p tcp 9998 +# sudo setsebool -P httpd_can_network_connect 1 +# +# 웹훅 등록 주소: +# https://gitea.palntour.com:9998/webhooks/gitea +# https://gitea.palntour.com:9998/webhooks/notion + +server { + listen 9998 ssl; + listen [::]:9998 ssl; + server_name gitea.palntour.com; + + ssl_certificate /etc/letsencrypt/live/gitea.palntour.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/gitea.palntour.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # 웹훅 본문이 큰 경우 대비 + client_max_body_size 5m; + + location / { + proxy_pass http://127.0.0.1:9999; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index a1b998e..7f18c1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: env_file: - .env.prod # 비밀값/설정 (ADDR은 넣지 말 것 — 컨테이너는 :8000 고정) ports: - - "6000:8000" # host:container — 리버스 프록시는 host:6000 → 컨테이너 8000 + - "9999:9999" # host:container — 리버스 프록시는 host:9999 → 컨테이너 8000 volumes: - ./data:/app/data # 유저 매핑(data/mappings.json) 영속화 - ./logs:/app/logs # production 로그(logs/app.log) 영속화 diff --git a/internal/config/config.go b/internal/config/config.go index 729f899..f1ad185 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,9 +20,9 @@ type Config struct { GiteaBroadcast bool // true면 모든 Gitea 이벤트를 DefaultSlackChannel로 브로드캐스트(기본 off, DM만) // Notion - NotionVerificationToken string - NotionAPIToken string - NotionAssigneeProperty string + NotionVerificationToken string + NotionAPIToken string + NotionAssigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) // App MappingFile string @@ -60,16 +60,16 @@ func Load(envOverride string) Config { _ = godotenv.Load(".env") return Config{ - SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), - DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), - MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"), - GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), - GiteaBroadcast: getenvBool("GITEA_BROADCAST", false), - 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"), + SlackBotToken: os.Getenv("SLACK_BOT_TOKEN"), + DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), + MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"), + GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), + GiteaBroadcast: getenvBool("GITEA_BROADCAST", false), + NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), + NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), + NotionAssigneeProperties: getenvList("NOTION_ASSIGNEE_PROPERTY", "담당자,처리자,참조인원"), + MappingFile: getenv("MAPPING_FILE", "data/mappings.json"), + Addr: getenv("ADDR", ":8000"), Env: env, LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)), @@ -124,6 +124,20 @@ func getenv(key, fallback string) string { return fallback } +// getenvList는 콤마로 구분된 값을 트림해 슬라이스로 돌려준다(빈 항목 제거). +// 미설정 시 fallback(역시 콤마 구분)을 파싱한다. +func getenvList(key, fallback string) []string { + v := getenv(key, fallback) + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if s := strings.TrimSpace(p); s != "" { + out = append(out, s) + } + } + return out +} + func getenvInt(key string, fallback int) int { if v := strings.TrimSpace(os.Getenv(key)); v != "" { if n, err := strconv.Atoi(v); err == nil { diff --git a/internal/notion/notion.go b/internal/notion/notion.go index e2b8d6c..f1df8a0 100644 --- a/internal/notion/notion.go +++ b/internal/notion/notion.go @@ -8,6 +8,8 @@ import ( "io" "log/slog" "net/http" + "strconv" + "strings" "time" "git/palnet/slack-notifier/internal/notify" @@ -16,39 +18,103 @@ import ( const ( apiBase = "https://api.notion.com/v1" version = "2022-06-28" + + // 본문 변경 시 DM에 표시할 블록 수 상한 (이벤트가 집계돼 다수일 수 있음). + maxChangedBlocks = 5 ) // Client calls the Notion API to resolve a page's assignees into emails. type Client struct { - token string - assigneeProperty string - http *http.Client + token string + assigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) + http *http.Client } -func New(token, assigneeProperty string) *Client { +func New(token string, assigneeProperties []string) *Client { return &Client{ - token: token, - assigneeProperty: assigneeProperty, - http: &http.Client{Timeout: 10 * time.Second}, + token: token, + assigneeProperties: assigneeProperties, + http: &http.Client{Timeout: 10 * time.Second}, } } +// ref is a generic {id, type} reference used across the webhook payload. +type ref struct { + ID string `json:"id"` + Type string `json:"type"` +} + type event struct { Type string `json:"type"` - Entity struct { - ID string `json:"id"` - Type string `json:"type"` - } `json:"entity"` + Entity ref `json:"entity"` + Data struct { + Parent ref `json:"parent"` + PageID string `json:"page_id"` // comment.* 이벤트에서 대상 페이지 + UpdatedProperties []string `json:"updated_properties"` // page.properties_updated: 속성 ID 목록 + UpdatedBlocks []ref `json:"updated_blocks"` // page.content_updated: 변경 블록 + } `json:"data"` + Authors []ref `json:"authors"` // 변경을 일으킨 사용자/봇 +} + +type richText struct { + PlainText string `json:"plain_text"` + Type string `json:"type"` // "text" | "mention" | ... + Mention *struct { + Type string `json:"type"` // "user" | "page" | "date" | ... + User ref `json:"user"` + } `json:"mention"` +} + +// plain joins rich_text into a single string. plain_text already renders +// @user 멘션을 표시 이름으로 포함하므로 표시용으로는 별도 멘션 처리가 필요 없다. +func plain(rt []richText) string { + var b strings.Builder + for _, t := range rt { + b.WriteString(t.PlainText) + } + return b.String() +} + +// mentionUserIDs extracts the user IDs @-mentioned within rich_text. +func mentionUserIDs(rt []richText) []string { + var ids []string + for _, t := range rt { + if t.Type == "mention" && t.Mention != nil && t.Mention.Type == "user" && t.Mention.User.ID != "" { + ids = append(ids, t.Mention.User.ID) + } + } + return ids +} + +// change summarizes one event: display lines plus any @-mentioned user IDs. +type change struct { + lines []string + mentionIDs []string } +type selectOption struct { + Name string `json:"name"` +} + +// property covers the property value shapes we render in change summaries. type property struct { - Type string `json:"type"` - Title []struct { - PlainText string `json:"plain_text"` - } `json:"title"` - People []struct { - ID string `json:"id"` - } `json:"people"` + ID string `json:"id"` + Type string `json:"type"` + Title []richText `json:"title"` + RichText []richText `json:"rich_text"` + Select *selectOption `json:"select"` + Status *selectOption `json:"status"` + MultiSelect []selectOption `json:"multi_select"` + People []ref `json:"people"` + Date *struct { + Start string `json:"start"` + End string `json:"end"` + } `json:"date"` + Checkbox *bool `json:"checkbox"` + Number *float64 `json:"number"` + URL *string `json:"url"` + Email *string `json:"email"` + PhoneNumber *string `json:"phone_number"` } type page struct { @@ -56,19 +122,29 @@ type page struct { Properties map[string]property `json:"properties"` } -// BuildNotifications parses a Notion event and returns DMs for the page assignees. +// BuildNotifications parses a Notion event and returns DMs for the page assignees, +// enriched with a human-readable summary of what changed. func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.Notification, error) { var ev event if err := json.Unmarshal(body, &ev); err != nil { return nil, err } - if ev.Entity.Type != "page" || ev.Entity.ID == "" { - slog.Info("notion event entity is not a page — ignored", "type", ev.Entity.Type) + + // 대상 페이지 결정: page.* 는 entity, comment.* 는 data.page_id. + pageID := "" + switch { + case ev.Entity.Type == "page" && ev.Entity.ID != "": + pageID = ev.Entity.ID + case strings.HasPrefix(ev.Type, "comment.") && ev.Data.PageID != "": + pageID = ev.Data.PageID + } + if pageID == "" { + slog.Info("notion event has no target page — ignored", "type", ev.Type, "entity", ev.Entity.Type) return nil, nil } var pg page - if err := c.get(ctx, "pages/"+ev.Entity.ID, &pg); err != nil { + if err := c.get(ctx, "pages/"+pageID, &pg); err != nil { slog.Warn("notion get page failed", "err", err) return nil, nil } @@ -78,36 +154,259 @@ func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify. if pg.URL != "" { body2 = fmt.Sprintf("<%s|%s>", pg.URL, title) } - evType := ev.Type - if evType == "" { - evType = "변경" + + label := eventLabel(ev.Type) + ch := c.describeChange(ctx, &ev, &pg) + if len(ch.lines) > 0 { + body2 += "\n\n*변경 내용*\n" + strings.Join(ch.lines, "\n") } - var notes []notify.Notification + // 작성자(이 변경/댓글을 일으킨 사람): 표시용 이름 + 자기 알림 제외용 이메일. + authorNames, authorEmails := c.resolveAuthors(ctx, &ev) + context := "" + if len(authorNames) > 0 { + context = "작성자: " + strings.Join(authorNames, ", ") + } + + // 수신자: 담당자(People 속성) ∪ 멘션된 사람. 값 true = 멘션으로 받는 사람. + recipients := make(map[string]bool) for _, id := range c.assigneeIDs(&pg) { - email := c.resolveEmail(ctx, id) - if email == "" { - slog.Info("notion user has no accessible email — skipped", "user_id", id) - continue + if email := c.resolveEmail(ctx, id); email != "" { + if _, ok := recipients[email]; !ok { + recipients[email] = false + } + } + } + for _, id := range ch.mentionIDs { + if email := c.resolveEmail(ctx, id); email != "" { + recipients[email] = true // 멘션이 더 구체적 — 라벨 우선 + } + } + + var notes []notify.Notification + for email, mentioned := range recipients { + if authorEmails[email] { + continue // 작성자 본인에게는 보내지 않음 + } + header := "🗒️ Notion — " + label + text := fmt.Sprintf("[Notion] %s — %s", label, title) + if mentioned { + header = "🔔 Notion 멘션 — " + label + text = fmt.Sprintf("[Notion] 멘션 — %s", title) } notes = append(notes, notify.Notification{ - Email: email, - Text: fmt.Sprintf("[Notion] 담당 페이지가 업데이트되었습니다 (%s)", evType), - Blocks: notify.SimpleBlocks( - "🗒️ Notion 업데이트 — "+evType, body2, ""), + Email: email, + Text: text, + Blocks: notify.SimpleBlocks(header, body2, context), }) } return notes, nil } +// describeChange summarizes what changed (display lines + mentioned users) by event type. +func (c *Client) describeChange(ctx context.Context, ev *event, pg *page) change { + switch ev.Type { + case "page.properties_updated": + return change{lines: c.changedProperties(ctx, ev, pg)} + case "page.content_updated": + return c.changedBlocks(ctx, ev) + case "comment.created", "comment.updated": + return c.commentText(ctx, ev) + case "page.created": + return change{lines: []string{"• 새 페이지가 생성되었습니다."}} + case "page.deleted": + return change{lines: []string{"• 페이지가 삭제(휴지통 이동)되었습니다."}} + case "page.undeleted": + return change{lines: []string{"• 페이지가 복원되었습니다."}} + case "page.moved": + return change{lines: []string{"• 페이지가 이동되었습니다."}} + case "comment.deleted": + return change{lines: []string{"• 댓글이 삭제되었습니다."}} + } + return change{} +} + +// changedProperties maps updated property IDs to their name + new value. +func (c *Client) changedProperties(ctx context.Context, ev *event, pg *page) []string { + if len(ev.Data.UpdatedProperties) == 0 { + return nil + } + nameByID := make(map[string]string, len(pg.Properties)) + propByID := make(map[string]property, len(pg.Properties)) + for name, p := range pg.Properties { + nameByID[p.ID] = name + propByID[p.ID] = p + } + var lines []string + for _, id := range ev.Data.UpdatedProperties { + name := nameByID[id] + if name == "" { + name = "(알 수 없는 속성)" + } + val := c.renderValue(ctx, propByID[id]) + if val == "" { + val = "(비어 있음)" + } + lines = append(lines, fmt.Sprintf("• *%s*: %s", name, val)) + } + return lines +} + +// renderValue formats a property's current value for display. +func (c *Client) renderValue(ctx context.Context, p property) string { + switch p.Type { + case "title": + return plain(p.Title) + case "rich_text": + return plain(p.RichText) + case "select": + if p.Select != nil { + return p.Select.Name + } + case "status": + if p.Status != nil { + return p.Status.Name + } + case "multi_select": + names := make([]string, 0, len(p.MultiSelect)) + for _, s := range p.MultiSelect { + names = append(names, s.Name) + } + return strings.Join(names, ", ") + case "date": + if p.Date != nil { + if p.Date.End != "" { + return p.Date.Start + " ~ " + p.Date.End + } + return p.Date.Start + } + case "checkbox": + if p.Checkbox != nil { + if *p.Checkbox { + return "✓ 체크됨" + } + return "✗ 해제됨" + } + case "number": + if p.Number != nil { + return strconv.FormatFloat(*p.Number, 'f', -1, 64) + } + case "url": + if p.URL != nil { + return *p.URL + } + case "email": + if p.Email != nil { + return *p.Email + } + case "phone_number": + if p.PhoneNumber != nil { + return *p.PhoneNumber + } + case "people": + names := make([]string, 0, len(p.People)) + for _, person := range p.People { + if n := c.resolveName(ctx, person.ID); n != "" { + names = append(names, n) + } + } + return strings.Join(names, ", ") + } + return "" +} + +// changedBlocks fetches updated blocks and renders text + mentioned users. +func (c *Client) changedBlocks(ctx context.Context, ev *event) change { + var ch change + for i, b := range ev.Data.UpdatedBlocks { + if i >= maxChangedBlocks { + ch.lines = append(ch.lines, fmt.Sprintf("• …외 %d개 블록 변경", len(ev.Data.UpdatedBlocks)-maxChangedBlocks)) + break + } + txt, ids := c.blockText(ctx, b.ID) + if txt != "" { + ch.lines = append(ch.lines, "• "+txt) + } + ch.mentionIDs = append(ch.mentionIDs, ids...) + } + return ch +} + +// blockText fetches a block and extracts the rich_text under its type key +// (paragraph/heading_*/bulleted_list_item/to_do/quote/callout/code/...), +// returning the rendered text and any @-mentioned user IDs. +func (c *Client) blockText(ctx context.Context, id string) (string, []string) { + var raw json.RawMessage + if err := c.get(ctx, "blocks/"+id, &raw); err != nil { + return "", nil + } + var head struct { + Type string `json:"type"` + } + if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" { + return "", nil + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + return "", nil + } + var inner struct { + RichText []richText `json:"rich_text"` + } + _ = json.Unmarshal(m[head.Type], &inner) // 타입별 키에만 rich_text 존재 + return strings.TrimSpace(plain(inner.RichText)), mentionUserIDs(inner.RichText) +} + +// commentText fetches the page's comments and returns the one matching the event, +// with its text and any @-mentioned user IDs. +func (c *Client) commentText(ctx context.Context, ev *event) change { + if ev.Data.PageID == "" { + return change{} + } + var res struct { + Results []struct { + ID string `json:"id"` + RichText []richText `json:"rich_text"` + } `json:"results"` + } + if err := c.get(ctx, "comments?block_id="+ev.Data.PageID, &res); err != nil { + slog.Warn("notion get comments failed", "err", err) + return change{} + } + for _, cm := range res.Results { + if cm.ID == ev.Entity.ID { + ch := change{mentionIDs: mentionUserIDs(cm.RichText)} + if txt := strings.TrimSpace(plain(cm.RichText)); txt != "" { + ch.lines = []string{"💬 " + txt} + } + return ch + } + } + return change{} +} + +// resolveAuthors returns display names and a set of emails for the event's authors. +func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, emails map[string]bool) { + emails = make(map[string]bool) + for _, a := range ev.Authors { + if a.ID == "" { + continue + } + name, email := c.resolveUser(ctx, a.ID) + if name != "" { + names = append(names, name) + } + if email != "" { + emails[email] = true + } + } + return names, emails +} + func pageTitle(pg *page) string { for _, prop := range pg.Properties { if prop.Type == "title" { - s := "" - for _, t := range prop.Title { - s += t.PlainText - } - if s != "" { + if s := plain(prop.Title); s != "" { return s } } @@ -115,35 +414,80 @@ func pageTitle(pg *page) string { return "(제목 없음)" } +// assigneeIDs collects user IDs from all configured People properties +// (담당자/처리자/참조인원 등), de-duplicated across properties. func (c *Client) assigneeIDs(pg *page) []string { - prop, ok := pg.Properties[c.assigneeProperty] - if !ok || prop.Type != "people" { - slog.Info("notion page has no people property", "property", c.assigneeProperty) - return nil - } - ids := make([]string, 0, len(prop.People)) - for _, p := range prop.People { - if p.ID != "" { - ids = append(ids, p.ID) + seen := make(map[string]bool) + var ids []string + for _, name := range c.assigneeProperties { + prop, ok := pg.Properties[name] + if !ok || prop.Type != "people" { + slog.Info("notion page has no people property", "property", name) + continue + } + for _, p := range prop.People { + if p.ID != "" && !seen[p.ID] { + seen[p.ID] = true + ids = append(ids, p.ID) + } } } return ids } -func (c *Client) resolveEmail(ctx context.Context, userID string) string { +// resolveUser returns a user's display name, and email when the user is a person. +func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) { var u struct { + Name string `json:"name"` Type string `json:"type"` Person struct { Email string `json:"email"` } `json:"person"` } if err := c.get(ctx, "users/"+userID, &u); err != nil { - return "" + return "", "" + } + if u.Type == "person" { + email = u.Person.Email } - if u.Type != "person" { - return "" + return u.Name, email +} + +func (c *Client) resolveEmail(ctx context.Context, userID string) string { + _, email := c.resolveUser(ctx, userID) + return email +} + +func (c *Client) resolveName(ctx context.Context, userID string) string { + name, _ := c.resolveUser(ctx, userID) + return name +} + +// eventLabel maps a Notion event type to a short Korean label. +func eventLabel(t string) string { + switch t { + case "page.created": + return "페이지 생성" + case "page.properties_updated": + return "속성 변경" + case "page.content_updated": + return "내용 변경" + case "page.moved": + return "페이지 이동" + case "page.deleted": + return "페이지 삭제" + case "page.undeleted": + return "페이지 복원" + case "comment.created": + return "새 댓글" + case "comment.updated": + return "댓글 수정" + case "comment.deleted": + return "댓글 삭제" + case "": + return "변경" } - return u.Person.Email + return t } func (c *Client) get(ctx context.Context, path string, out any) error { diff --git a/internal/notion/notion_test.go b/internal/notion/notion_test.go new file mode 100644 index 0000000..1d2c1a2 --- /dev/null +++ b/internal/notion/notion_test.go @@ -0,0 +1,145 @@ +package notion + +import ( + "context" + "testing" +) + +func ptrBool(b bool) *bool { return &b } +func ptrFloat(f float64) *float64 { return &f } +func ptrStr(s string) *string { return &s } + +func TestRenderValue(t *testing.T) { + c := &Client{} // network-free property types only + ctx := context.Background() + + cases := []struct { + name string + prop property + want string + }{ + {"title", property{Type: "title", Title: []richText{{PlainText: "기획 "}, {PlainText: "문서"}}}, "기획 문서"}, + {"rich_text", property{Type: "rich_text", RichText: []richText{{PlainText: "메모"}}}, "메모"}, + {"select", property{Type: "select", Select: &selectOption{Name: "진행중"}}, "진행중"}, + {"status", property{Type: "status", Status: &selectOption{Name: "완료"}}, "완료"}, + {"multi_select", property{Type: "multi_select", MultiSelect: []selectOption{{Name: "A"}, {Name: "B"}}}, "A, B"}, + {"checkbox_on", property{Type: "checkbox", Checkbox: ptrBool(true)}, "✓ 체크됨"}, + {"checkbox_off", property{Type: "checkbox", Checkbox: ptrBool(false)}, "✗ 해제됨"}, + {"number", property{Type: "number", Number: ptrFloat(42)}, "42"}, + {"url", property{Type: "url", URL: ptrStr("https://x")}, "https://x"}, + {"email", property{Type: "email", Email: ptrStr("a@b.com")}, "a@b.com"}, + {"empty_select", property{Type: "select"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := c.renderValue(ctx, tc.prop); got != tc.want { + t.Errorf("renderValue = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRenderDate(t *testing.T) { + c := &Client{} + ctx := context.Background() + single := property{Type: "date"} + single.Date = &struct { + Start string `json:"start"` + End string `json:"end"` + }{Start: "2026-06-20"} + if got := c.renderValue(ctx, single); got != "2026-06-20" { + t.Errorf("single date = %q", got) + } + rng := property{Type: "date"} + rng.Date = &struct { + Start string `json:"start"` + End string `json:"end"` + }{Start: "2026-06-20", End: "2026-06-25"} + if got := c.renderValue(ctx, rng); got != "2026-06-20 ~ 2026-06-25" { + t.Errorf("range date = %q", got) + } +} + +func TestChangedProperties(t *testing.T) { + c := &Client{} + pg := &page{Properties: map[string]property{ + "상태": {ID: "p1", Type: "status", Status: &selectOption{Name: "진행중"}}, + "우선순위": {ID: "p2", Type: "select", Select: &selectOption{Name: "높음"}}, + "제목": {ID: "title", Type: "title", Title: []richText{{PlainText: "T"}}}, + }} + ev := &event{} + ev.Data.UpdatedProperties = []string{"p1", "p2"} + + lines := c.changedProperties(context.Background(), ev, pg) + if len(lines) != 2 { + t.Fatalf("want 2 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "• *상태*: 진행중" { + t.Errorf("line0 = %q", lines[0]) + } + if lines[1] != "• *우선순위*: 높음" { + t.Errorf("line1 = %q", lines[1]) + } +} + +func TestMentionUserIDs(t *testing.T) { + mk := func(typ, uid, plain string) richText { + rt := richText{Type: typ, PlainText: plain} + if uid != "" { + rt.Mention = &struct { + Type string `json:"type"` + User ref `json:"user"` + }{Type: "user", User: ref{ID: uid}} + } + return rt + } + rt := []richText{ + mk("text", "", "안녕 "), + mk("mention", "u1", "@지대한"), + mk("text", "", " 확인 부탁 "), + mk("mention", "u2", "@김영운"), + } + got := mentionUserIDs(rt) + if len(got) != 2 || got[0] != "u1" || got[1] != "u2" { + t.Fatalf("mentionUserIDs = %v, want [u1 u2]", got) + } + // 멘션 없는 rich_text → 빈 결과 + if ids := mentionUserIDs([]richText{mk("text", "", "그냥 텍스트")}); len(ids) != 0 { + t.Errorf("expected no mentions, got %v", ids) + } +} + +func TestAssigneeIDs(t *testing.T) { + c := &Client{assigneeProperties: []string{"담당자", "처리자", "참조인원"}} + pg := &page{Properties: map[string]property{ + "담당자": {Type: "people", People: []ref{{ID: "u1"}, {ID: "u2"}}}, + "처리자": {Type: "people", People: []ref{{ID: "u2"}, {ID: "u3"}}}, // u2 중복 + "참조인원": {Type: "people", People: []ref{{ID: "u4"}}}, + "상태": {Type: "status", Status: &selectOption{Name: "진행중"}}, // people 아님 → 무시 + }} + got := c.assigneeIDs(pg) + want := []string{"u1", "u2", "u3", "u4"} // 설정 순서대로, 중복 제거 + if len(got) != len(want) { + t.Fatalf("assigneeIDs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("assigneeIDs[%d] = %q, want %q (%v)", i, got[i], want[i], got) + } + } +} + +func TestEventLabel(t *testing.T) { + cases := map[string]string{ + "page.properties_updated": "속성 변경", + "page.content_updated": "내용 변경", + "comment.created": "새 댓글", + "": "변경", + "page.unknown": "page.unknown", + } + for in, want := range cases { + if got := eventLabel(in); got != want { + t.Errorf("eventLabel(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/main.go b/main.go index 196ee7c..cbe058f 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,7 @@ func main() { os.Exit(1) } - notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperty) + notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperties) srv := server.New(cfg, slackClient, store, notionClient)