Browse Source
- Notion DM에 변경 내용 요약 추가(속성 변경=이름+새 값, 본문=블록 텍스트, 댓글=본문, 생성/삭제/이동/복원 라벨, 변경자 표시) - 본문·댓글의 @멘션 대상도 수신자에 포함(작성자 본인 제외) - 수신 People 속성을 다중화: 담당자/처리자/참조인원(콤마 구분, 중복 제거) - Caddy 리버스 프록시 구성: 443+9998 동시 서빙, 포트 80 없이 TLS-ALPN-01로 인증서 발급(Notion은 비표준 포트 거부 → 443 사용) - notion_test.go: 렌더링/멘션 추출/속성 dedup 테스트 추가main
9 changed files with 635 additions and 79 deletions
@ -1,14 +1,28 @@ |
|||||||
# slack-notifier 앞단 HTTPS 리버스 프록시 (Caddy). |
# 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 검증 + 웹훅 수신) |
# - 443 : 표준 HTTPS. Notion 등 비표준 포트를 거부하는 웹훅 제공자용. (Notion은 이 주소로 등록) |
||||||
# 3) caddy 설치 후: sudo cp deploy/Caddyfile /etc/caddy/Caddyfile && sudo systemctl restart caddy |
# - 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 |
# Gitea : https://gitea.palntour.com:9998/webhooks/gitea |
||||||
# https://your-domain.com/webhooks/notion |
# Notion: https://gitea.palntour.com/webhooks/notion (← 포트 생략 = 443) |
||||||
|
|
||||||
|
{ |
||||||
|
# 80을 쓰는 다른 앱과 충돌 방지 (HTTP→HTTPS 자동 리다이렉트 끔) |
||||||
|
auto_https disable_redirects |
||||||
|
} |
||||||
|
|
||||||
your-domain.com { |
gitea.palntour.com:443, gitea.palntour.com:9998 { |
||||||
reverse_proxy localhost:8000 |
reverse_proxy localhost:9999 |
||||||
} |
} |
||||||
|
|||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue