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