Browse Source

feat: Notion 알림 강화 + HTTPS(Caddy) 배포 구성

- Notion DM에 변경 내용 요약 추가(속성 변경=이름+새 값, 본문=블록 텍스트, 댓글=본문, 생성/삭제/이동/복원 라벨, 변경자 표시)
- 본문·댓글의 @멘션 대상도 수신자에 포함(작성자 본인 제외)
- 수신 People 속성을 다중화: 담당자/처리자/참조인원(콤마 구분, 중복 제거)
- Caddy 리버스 프록시 구성: 443+9998 동시 서빙, 포트 80 없이 TLS-ALPN-01로 인증서 발급(Notion은 비표준 포트 거부 → 443 사용)
- notion_test.go: 렌더링/멘션 추출/속성 dedup 테스트 추가
main
지대한 1 week ago
parent
commit
c405c5a0e2
  1. 4
      .env.example
  2. 2
      README.md
  3. 30
      deploy/Caddyfile
  4. 39
      deploy/nginx-slack-notifier.conf
  5. 2
      docker-compose.yml
  6. 18
      internal/config/config.go
  7. 428
      internal/notion/notion.go
  8. 145
      internal/notion/notion_test.go
  9. 2
      main.go

4
.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 ---
# 수동 유저 매핑 저장 파일 (관리 페이지에서 편집)

2
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`, 콤마 구분, 기본 `담당자,처리자,참조인원`)에서 읽어 이메일로 매핑. 여러 속성의 인원을 합쳐(중복 제거) 모두에게 발송. 본문/댓글에서 **@멘션된 사람**도 수신자에 추가됨(작성자 본인 제외).
## 저사양 배포 메모

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

39
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;
}
}

2
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) 영속화

18
internal/config/config.go

@ -22,7 +22,7 @@ type Config struct {
// Notion
NotionVerificationToken string
NotionAPIToken string
NotionAssigneeProperty string
NotionAssigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등)
// App
MappingFile string
@ -67,7 +67,7 @@ func Load(envOverride string) Config {
GiteaBroadcast: getenvBool("GITEA_BROADCAST", false),
NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"),
NotionAPIToken: os.Getenv("NOTION_API_TOKEN"),
NotionAssigneeProperty: getenv("NOTION_ASSIGNEE_PROPERTY", "담당자"),
NotionAssigneeProperties: getenvList("NOTION_ASSIGNEE_PROPERTY", "담당자,처리자,참조인원"),
MappingFile: getenv("MAPPING_FILE", "data/mappings.json"),
Addr: getenv("ADDR", ":8000"),
@ -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 {

428
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
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,
assigneeProperties: assigneeProperties,
http: &http.Client{Timeout: 10 * time.Second},
}
}
type event struct {
Type string `json:"type"`
Entity struct {
// ref is a generic {id, type} reference used across the webhook payload.
type ref struct {
ID string `json:"id"`
Type string `json:"type"`
} `json:"entity"`
}
type property struct {
type event struct {
Type string `json:"type"`
Title []struct {
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"`
} `json:"title"`
People []struct {
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 {
ID string `json:"id"`
} `json:"people"`
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, ""),
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]
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", c.assigneeProperty)
return nil
slog.Info("notion page has no people property", "property", name)
continue
}
ids := make([]string, 0, len(prop.People))
for _, p := range prop.People {
if p.ID != "" {
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" {
return ""
if u.Type == "person" {
email = u.Person.Email
}
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 {

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

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

Loading…
Cancel
Save