gitea, notion webhook
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

779 lines
21 KiB

// Package notion maps Notion webhook events to DMs for a page's assignees,
// summarizing what changed (이전→현재 diff) using a per-page snapshot store.
package notion
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"git/palnet/slack-notifier/internal/notify"
)
const (
apiBase = "https://api.notion.com/v1"
version = "2022-06-28"
maxChangedBlocks = 5 // 본문 변경 시 표시할 블록 수 상한
valueRunes = 40 // 값 1개의 최대 표시 글자 수
commentRunes = 120 // 댓글 본문 표시 최대 글자 수
)
// Client calls the Notion API and diffs against stored snapshots.
type Client struct {
token string
assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등)
snap *SnapshotStore
http *http.Client
userMu sync.Mutex
userCache map[string]userInfo // userID → 조회 결과 (성공/실패 모두 캐시)
}
// userInfo is the cached result of a users/{id} lookup. ok=false면 조회 실패라
// 수신자 목록에서 제외한다(빈 이메일).
type userInfo struct {
name string
email string
ok bool
}
func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client {
return &Client{
token: token,
assigneeProperties: assigneeProperties,
snap: snap,
http: &http.Client{Timeout: 10 * time.Second},
userCache: make(map[string]userInfo),
}
}
// 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 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"`
Mention *struct {
Type string `json:"type"`
User ref `json:"user"`
} `json:"mention"`
}
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
}
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"`
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 {
URL string `json:"url"`
Properties map[string]property `json:"properties"`
}
// BuildNotifications parses a Notion event and returns DMs for the page's
// recipients (담당자 속성들 ∪ @멘션), with a compact 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
}
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
}
// page.deleted: 마지막 상태를 알림에 동봉하고 스냅샷 제거.
if ev.Type == "page.deleted" {
return c.handleDeleted(ctx, &ev, pageID), nil
}
var pg page
if err := c.get(ctx, "pages/"+pageID, &pg); err != nil {
slog.Warn("notion get page failed", "err", err)
return nil, nil
}
title := pageTitle(&pg)
body2, mentionIDs := c.summarize(ctx, &ev, &pg, pageID, title)
return c.deliver(ctx, &pg, &ev, eventLabel(ev.Type), titleLink(&pg, title), title, body2, mentionIDs), nil
}
// summarize produces the compact body and any @-mentioned user IDs, and
// updates the page snapshot as a side effect.
func (c *Client) summarize(ctx context.Context, ev *event, pg *page, pageID, title string) (string, []string) {
switch ev.Type {
case "page.properties_updated":
return c.summarizeProps(ctx, ev, pg, pageID, title), nil
case "page.content_updated":
return c.summarizeContent(ctx, ev, pg, pageID, title)
case "comment.created", "comment.updated":
return c.summarizeComment(ctx, ev)
case "page.created":
cur := c.renderAllProps(ctx, pg)
c.snapPut(pageID, snapshot{Title: title, Props: cur})
return currentLines(cur, propOrder(pg)), nil
case "page.moved":
return "페이지가 이동되었습니다.", nil
case "page.undeleted":
return "페이지가 복원되었습니다.", nil
case "comment.deleted":
return "댓글이 삭제되었습니다.", nil
}
return "", nil
}
// summarizeProps diffs updated properties against the snapshot (이전 ➜ 현재).
func (c *Client) summarizeProps(ctx context.Context, ev *event, pg *page, pageID, title string) string {
prev, _ := c.snapGet(pageID)
cur := c.renderAllProps(ctx, pg)
type meta struct{ name, ptype string }
byID := make(map[string]meta, len(pg.Properties))
for name, p := range pg.Properties {
byID[p.ID] = meta{name, p.Type}
}
var lines, changedNames []string
for _, id := range ev.Data.UpdatedProperties {
m := byID[id]
if m.name == "" {
continue
}
changedNames = append(changedNames, m.name)
lines = append(lines, diffLines(m.name, prev.Props[m.name], cur[m.name], m.ptype)...)
}
c.snapPut(pageID, snapshot{Title: title, Props: cur, Blocks: prev.Blocks})
if len(lines) == 0 {
if len(changedNames) > 0 {
return "[변경] " + strings.Join(changedNames, ", ")
}
return "[변경] 속성이 변경되었습니다."
}
return strings.Join(lines, "\n")
}
// summarizeContent diffs updated blocks against the snapshot (+추가/~수정/-삭제).
func (c *Client) summarizeContent(ctx context.Context, ev *event, pg *page, pageID, title string) (string, []string) {
prev, _ := c.snapGet(pageID)
blocks := make(map[string]string, len(prev.Blocks))
for k, v := range prev.Blocks {
blocks[k] = v
}
var lines, mentions []string
for i, b := range ev.Data.UpdatedBlocks {
if i >= maxChangedBlocks {
lines = append(lines, fmt.Sprintf("[그 외] 본문 %d건 더 변경", len(ev.Data.UpdatedBlocks)-maxChangedBlocks))
break
}
text, ms, exists := c.blockInfo(ctx, b.ID)
mentions = append(mentions, ms...)
old := prev.Blocks[b.ID]
switch {
case !exists: // 삭제됨
if old != "" {
lines = append(lines, "[삭제] 본문: \""+truncRunes(old, valueRunes)+"\"")
} else {
lines = append(lines, "[삭제] 본문")
}
delete(blocks, b.ID)
case old == "": // 신규
if text != "" {
lines = append(lines, "[추가] 본문: \""+truncRunes(text, valueRunes)+"\"")
blocks[b.ID] = text
}
case old != text: // 수정
lines = append(lines, "[변경] 본문: \""+truncRunes(old, valueRunes)+"\" → \""+truncRunes(text, valueRunes)+"\"")
blocks[b.ID] = text
default: // 변화 없음(서식 등) — 생략
blocks[b.ID] = text
}
}
c.snapPut(pageID, snapshot{Title: title, Props: prev.Props, Blocks: blocks})
if len(lines) == 0 {
return "[변경] 본문이 변경되었습니다.", mentions
}
return strings.Join(lines, "\n"), mentions
}
// summarizeComment returns the matching comment's text + mentioned users.
func (c *Client) summarizeComment(ctx context.Context, ev *event) (string, []string) {
if ev.Data.PageID == "" {
return "", nil
}
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 "", nil
}
for _, cm := range res.Results {
if cm.ID == ev.Entity.ID {
txt := strings.TrimSpace(plain(cm.RichText))
if txt == "" {
return "", mentionUserIDs(cm.RichText)
}
return "[댓글] " + truncRunes(txt, commentRunes), mentionUserIDs(cm.RichText)
}
}
return "", nil
}
// handleDeleted sends a "마지막 상태" notice from the snapshot, then drops it.
func (c *Client) handleDeleted(ctx context.Context, ev *event, pageID string) []notify.Notification {
prev, ok := c.snapGet(pageID)
// archived 페이지도 조회 가능 — 수신자/제목 확보용.
var pg page
title := prev.Title
if err := c.get(ctx, "pages/"+pageID, &pg); err == nil {
if t := pageTitle(&pg); t != "(제목 없음)" {
title = t
}
}
if title == "" {
title = "(제목 없음)"
}
body := "페이지가 삭제(휴지통 이동)되었습니다."
if ok && len(prev.Props) > 0 {
body = "마지막 상태:\n" + currentLines(prev.Props, sortedKeys(prev.Props))
}
notes := c.deliver(ctx, &pg, ev, "페이지 삭제", titleLink(&pg, title), title, body, nil)
c.snapDelete(pageID)
return notes
}
// deliver builds one DM per recipient (담당자 ∪ 멘션, 작성자 제외).
func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, headerTitle, title, body string, mentionIDs []string) []notify.Notification {
authorNames, authorEmails := c.resolveAuthors(ctx, ev)
context := ""
if len(authorNames) > 0 {
context = "by " + strings.Join(authorNames, ", ")
}
assigneeIDs := c.assigneeIDs(pg)
recipients := make(map[string]bool) // email → mentioned?
for _, id := range assigneeIDs {
if email := c.resolveEmail(ctx, id); email != "" {
if _, ok := recipients[email]; !ok {
recipients[email] = false
}
}
}
for _, id := range mentionIDs {
if email := c.resolveEmail(ctx, id); email != "" {
recipients[email] = true
}
}
// 수신자 해석 결과를 남겨 "왜 DM이 안 갔는지"를 로그로 추적 가능하게 한다.
excluded := 0
for email := range recipients {
if authorEmails[email] {
excluded++
}
}
slog.Info("notion recipients resolved",
"event", ev.Type, "label", label,
"assigneeProps", len(assigneeIDs), "mentions", len(mentionIDs),
"recipients", len(recipients), "authorExcluded", excluded)
var notes []notify.Notification
for email, mentioned := range recipients {
if authorEmails[email] {
continue
}
tag := "[Notion]"
text := fmt.Sprintf("[Notion] %s — %s", label, title)
if mentioned {
tag = "[멘션]"
text = fmt.Sprintf("[멘션] %s — %s", label, title)
}
header := tag + " " + label + " — " + headerTitle
notes = append(notes, notify.Notification{
Email: email,
Text: text,
Blocks: notify.SimpleBlocks(header, body, context),
})
}
return notes
}
// --- diff helpers ---
// diffLines formats one property change as labeled lines. List-type
// (people/multi_select)은 항목별 [추가]/[삭제], 그 외는 [변경] 이전 → 현재.
// 변화가 없으면 빈 슬라이스.
func diffLines(name, oldVal, newVal, ptype string) []string {
if ptype == "people" || ptype == "multi_select" {
add, rem := listDiff(oldVal, newVal)
var lines []string
for _, a := range add {
lines = append(lines, "[추가] "+name+": "+a)
}
for _, r := range rem {
lines = append(lines, "[삭제] "+name+": "+r)
}
return lines
}
if oldVal == newVal {
return nil
}
o, n := truncRunes(oldVal, valueRunes), truncRunes(newVal, valueRunes)
switch {
case oldVal == "":
return []string{"[변경] " + name + ": " + n + " (신규)"}
case newVal == "":
return []string{"[삭제] " + name + ": " + o}
default:
return []string{"[변경] " + name + ": " + o + " → " + n}
}
}
func listDiff(oldVal, newVal string) (added, removed []string) {
o, n := splitList(oldVal), splitList(newVal)
oset, nset := toSet(o), toSet(n)
for _, x := range n {
if !oset[x] {
added = append(added, x)
}
}
for _, x := range o {
if !nset[x] {
removed = append(removed, x)
}
}
return added, removed
}
func splitList(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var out []string
for _, p := range strings.Split(s, ", ") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func toSet(xs []string) map[string]bool {
m := make(map[string]bool, len(xs))
for _, x := range xs {
m[x] = true
}
return m
}
// currentLines renders "name: value" lines for the given key order.
func currentLines(props map[string]string, order []string) string {
var lines []string
for _, name := range order {
if v := props[name]; v != "" {
lines = append(lines, name+": "+truncRunes(v, valueRunes))
}
}
return strings.Join(lines, "\n")
}
func truncRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
// --- value rendering ---
// renderAllProps renders every property's current value (속성명 → 값).
func (c *Client) renderAllProps(ctx context.Context, pg *page) map[string]string {
out := make(map[string]string, len(pg.Properties))
for name, p := range pg.Properties {
out[name] = c.renderValue(ctx, p)
}
return out
}
// propOrder returns property names ordered by their Notion property ID,
// for a stable display order across events.
func propOrder(pg *page) []string {
type kv struct{ name, id string }
kvs := make([]kv, 0, len(pg.Properties))
for name, p := range pg.Properties {
kvs = append(kvs, kv{name, p.ID})
}
sort.Slice(kvs, func(i, j int) bool { return kvs[i].id < kvs[j].id })
out := make([]string, len(kvs))
for i, k := range kvs {
out[i] = k.name
}
return out
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
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 ""
}
// blockInfo fetches a block: its text, @-mentioned users, and whether it still
// exists (false = 삭제/휴지통 → 호출자가 마지막 텍스트를 스냅샷에서 가져감).
func (c *Client) blockInfo(ctx context.Context, id string) (text string, mentions []string, exists bool) {
var raw json.RawMessage
if err := c.get(ctx, "blocks/"+id, &raw); err != nil {
return "", nil, false
}
var head struct {
Type string `json:"type"`
Archived bool `json:"archived"`
InTrash bool `json:"in_trash"`
}
if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" {
return "", nil, false
}
if head.Archived || head.InTrash {
return "", nil, false
}
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
return "", nil, true
}
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), true
}
// --- page / user helpers ---
func titleLink(pg *page, title string) string {
if pg.URL != "" {
return fmt.Sprintf("<%s|%s>", pg.URL, title)
}
return title
}
func pageTitle(pg *page) string {
for _, prop := range pg.Properties {
if prop.Type == "title" {
if s := plain(prop.Title); s != "" {
return s
}
}
}
return "(제목 없음)"
}
// assigneeIDs collects user IDs from all configured People properties,
// de-duplicated across properties.
func (c *Client) assigneeIDs(pg *page) []string {
seen := make(map[string]bool)
var ids []string
for _, name := range c.assigneeProperties {
prop, ok := pg.Properties[name]
if !ok || prop.Type != "people" {
continue
}
for _, p := range prop.People {
if p.ID != "" && !seen[p.ID] {
seen[p.ID] = true
ids = append(ids, p.ID)
}
}
}
return ids
}
// resolveAuthors returns display names and a set of emails for event 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
}
// resolveUser looks up a user's name/email via users/{id}, memoizing the
// result (성공·실패 모두). 조회 실패 시 빈 이메일을 돌려주고 호출부는 수신자
// 목록에서 제외한다. 같은 이벤트 안 중복 조회를 없애 레이트리밋을 피한다.
func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) {
if userID == "" {
return "", ""
}
c.userMu.Lock()
if cached, ok := c.userCache[userID]; ok {
c.userMu.Unlock()
return cached.name, cached.email
}
c.userMu.Unlock()
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 {
// 실패도 캐시(이벤트 내 재시도 폭주 방지)하되, 경고는 남긴다.
slog.Warn("notion resolve user failed — 수신자에서 제외", "user", userID, "err", err)
c.userMu.Lock()
c.userCache[userID] = userInfo{ok: false}
c.userMu.Unlock()
return "", ""
}
if u.Type == "person" {
email = u.Person.Email
}
if email == "" {
slog.Warn("notion user has no email — 수신자에서 제외", "user", userID, "name", u.Name, "type", u.Type)
}
c.userMu.Lock()
c.userCache[userID] = userInfo{name: u.Name, email: email, ok: true}
c.userMu.Unlock()
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
}
// --- snapshot wrappers (nil-safe) ---
func (c *Client) snapGet(pageID string) (snapshot, bool) {
if c.snap == nil {
return snapshot{}, false
}
return c.snap.Get(pageID)
}
func (c *Client) snapPut(pageID string, s snapshot) {
if c.snap != nil {
c.snap.Put(pageID, s)
}
}
func (c *Client) snapDelete(pageID string) {
if c.snap != nil {
c.snap.Delete(pageID)
}
}
// 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 t
}
func (c *Client) get(ctx context.Context, path string, out any) error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/"+path, nil)
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Notion-Version", version)
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
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("notion %s: status %d: %s", path, resp.StatusCode, truncate(data, 200))
}
return json.Unmarshal(data, out)
}
func truncate(b []byte, n int) string {
if len(b) > n {
return string(b[:n])
}
return string(b)
}