Browse Source

feat(notion): 변경 이력 diff 알림 + 다중 담당자 속성

- 스냅샷 저장소(internal/notion/snapshot.go, data/notion-snapshots.json)
  도입: 페이지당 최신 1개만 덮어쓰기(과거 이력 누적 안 함)
- 속성은 이전→현재 diff([변경]/[추가]/[삭제] 태그 라벨형),
  목록형(담당자/태그)은 항목별 추가·삭제 표시
- 본문은 블록 추가/수정/삭제 감지(archived), 댓글은 본문+멘션 표시
- page.deleted 시 마지막 상태를 동봉하고 스냅샷 제거
- 수신 People 속성 다중화: 담당자/처리자/참조인원(콤마 구분, dedup)
- @멘션 대상을 수신자에 추가(작성자 본인 제외), 멘션 수신은 [멘션] 헤더
- config: NOTION_SNAPSHOT_FILE 추가
- 테스트: diffLines/listDiff/truncRunes/snapshot 라운드트립

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
지대한 1 week ago
parent
commit
59fc1d5962
  1. 2
      .env.example
  2. 2
      internal/config/config.go
  3. 511
      internal/notion/notion.go
  4. 80
      internal/notion/notion_test.go
  5. 89
      internal/notion/snapshot.go
  6. 7
      main.go

2
.env.example

@ -27,6 +27,8 @@ NOTION_VERIFICATION_TOKEN=
NOTION_API_TOKEN= NOTION_API_TOKEN=
# 알림 대상이 들어있는 Notion People 속성 이름들 (콤마 구분, 모두 수신자에 포함) # 알림 대상이 들어있는 Notion People 속성 이름들 (콤마 구분, 모두 수신자에 포함)
NOTION_ASSIGNEE_PROPERTY=담당자,처리자,참조인원 NOTION_ASSIGNEE_PROPERTY=담당자,처리자,참조인원
# 페이지 스냅샷 저장 파일 (이전→현재 diff용). 비우면 메모리만 사용(재시작 시 diff 초기화)
NOTION_SNAPSHOT_FILE=data/notion-snapshots.json
# --- App --- # --- App ---
# 수동 유저 매핑 저장 파일 (관리 페이지에서 편집) # 수동 유저 매핑 저장 파일 (관리 페이지에서 편집)

2
internal/config/config.go

@ -23,6 +23,7 @@ type Config struct {
NotionVerificationToken string NotionVerificationToken string
NotionAPIToken string NotionAPIToken string
NotionAssigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) NotionAssigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등)
NotionSnapshotFile string // 페이지 스냅샷 저장 파일 (이전→현재 diff용). 비우면 메모리만
// App // App
MappingFile string MappingFile string
@ -68,6 +69,7 @@ func Load(envOverride string) Config {
NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"),
NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), NotionAPIToken: os.Getenv("NOTION_API_TOKEN"),
NotionAssigneeProperties: getenvList("NOTION_ASSIGNEE_PROPERTY", "담당자,처리자,참조인원"), NotionAssigneeProperties: getenvList("NOTION_ASSIGNEE_PROPERTY", "담당자,처리자,참조인원"),
NotionSnapshotFile: getenv("NOTION_SNAPSHOT_FILE", "data/notion-snapshots.json"),
MappingFile: getenv("MAPPING_FILE", "data/mappings.json"), MappingFile: getenv("MAPPING_FILE", "data/mappings.json"),
Addr: getenv("ADDR", ":8000"), Addr: getenv("ADDR", ":8000"),

511
internal/notion/notion.go

@ -1,4 +1,5 @@
// Package notion maps Notion webhook events to DMs for a page's assignees. // 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 package notion
import ( import (
@ -8,6 +9,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -19,21 +21,24 @@ const (
apiBase = "https://api.notion.com/v1" apiBase = "https://api.notion.com/v1"
version = "2022-06-28" version = "2022-06-28"
// 본문 변경 시 DM에 표시할 블록 수 상한 (이벤트가 집계돼 다수일 수 있음). maxChangedBlocks = 5 // 본문 변경 시 표시할 블록 수 상한
maxChangedBlocks = 5 valueRunes = 40 // 값 1개의 최대 표시 글자 수
commentRunes = 120 // 댓글 본문 표시 최대 글자 수
) )
// Client calls the Notion API to resolve a page's assignees into emails. // Client calls the Notion API and diffs against stored snapshots.
type Client struct { type Client struct {
token string token string
assigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등)
snap *SnapshotStore
http *http.Client http *http.Client
} }
func New(token string, assigneeProperties []string) *Client { func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client {
return &Client{ return &Client{
token: token, token: token,
assigneeProperties: assigneeProperties, assigneeProperties: assigneeProperties,
snap: snap,
http: &http.Client{Timeout: 10 * time.Second}, http: &http.Client{Timeout: 10 * time.Second},
} }
} }
@ -58,15 +63,13 @@ type event struct {
type richText struct { type richText struct {
PlainText string `json:"plain_text"` PlainText string `json:"plain_text"`
Type string `json:"type"` // "text" | "mention" | ... Type string `json:"type"`
Mention *struct { Mention *struct {
Type string `json:"type"` // "user" | "page" | "date" | ... Type string `json:"type"`
User ref `json:"user"` User ref `json:"user"`
} `json:"mention"` } `json:"mention"`
} }
// plain joins rich_text into a single string. plain_text already renders
// @user 멘션을 표시 이름으로 포함하므로 표시용으로는 별도 멘션 처리가 필요 없다.
func plain(rt []richText) string { func plain(rt []richText) string {
var b strings.Builder var b strings.Builder
for _, t := range rt { for _, t := range rt {
@ -86,12 +89,6 @@ func mentionUserIDs(rt []richText) []string {
return ids return ids
} }
// change summarizes one event: display lines plus any @-mentioned user IDs.
type change struct {
lines []string
mentionIDs []string
}
type selectOption struct { type selectOption struct {
Name string `json:"name"` Name string `json:"name"`
} }
@ -122,15 +119,14 @@ type page struct {
Properties map[string]property `json:"properties"` 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's
// enriched with a human-readable summary of what changed. // recipients (담당자 속성들 ∪ @멘션), with a compact summary of what changed.
func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.Notification, error) { func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.Notification, error) {
var ev event var ev event
if err := json.Unmarshal(body, &ev); err != nil { if err := json.Unmarshal(body, &ev); err != nil {
return nil, err return nil, err
} }
// 대상 페이지 결정: page.* 는 entity, comment.* 는 data.page_id.
pageID := "" pageID := ""
switch { switch {
case ev.Entity.Type == "page" && ev.Entity.ID != "": case ev.Entity.Type == "page" && ev.Entity.ID != "":
@ -143,116 +139,344 @@ func (c *Client) BuildNotifications(ctx context.Context, body []byte) ([]notify.
return nil, nil return nil, nil
} }
// page.deleted: 마지막 상태를 알림에 동봉하고 스냅샷 제거.
if ev.Type == "page.deleted" {
return c.handleDeleted(ctx, &ev, pageID), nil
}
var pg page var pg page
if err := c.get(ctx, "pages/"+pageID, &pg); err != nil { if err := c.get(ctx, "pages/"+pageID, &pg); err != nil {
slog.Warn("notion get page failed", "err", err) slog.Warn("notion get page failed", "err", err)
return nil, nil return nil, nil
} }
title := pageTitle(&pg) title := pageTitle(&pg)
body2 := title body2, mentionIDs := c.summarize(ctx, &ev, &pg, pageID, title)
if pg.URL != "" {
body2 = fmt.Sprintf("<%s|%s>", pg.URL, 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 = "(제목 없음)"
} }
label := eventLabel(ev.Type) body := "페이지가 삭제(휴지통 이동)되었습니다."
ch := c.describeChange(ctx, &ev, &pg) if ok && len(prev.Props) > 0 {
if len(ch.lines) > 0 { body = "마지막 상태:\n" + currentLines(prev.Props, sortedKeys(prev.Props))
body2 += "\n\n*변경 내용*\n" + strings.Join(ch.lines, "\n")
} }
// 작성자(이 변경/댓글을 일으킨 사람): 표시용 이름 + 자기 알림 제외용 이메일. notes := c.deliver(ctx, &pg, ev, "페이지 삭제", titleLink(&pg, title), title, body, nil)
authorNames, authorEmails := c.resolveAuthors(ctx, &ev) 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 := "" context := ""
if len(authorNames) > 0 { if len(authorNames) > 0 {
context = "작성자: " + strings.Join(authorNames, ", ") context = "by " + strings.Join(authorNames, ", ")
} }
// 수신자: 담당자(People 속성) ∪ 멘션된 사람. 값 true = 멘션으로 받는 사람. recipients := make(map[string]bool) // email → mentioned?
recipients := make(map[string]bool) for _, id := range c.assigneeIDs(pg) {
for _, id := range c.assigneeIDs(&pg) {
if email := c.resolveEmail(ctx, id); email != "" { if email := c.resolveEmail(ctx, id); email != "" {
if _, ok := recipients[email]; !ok { if _, ok := recipients[email]; !ok {
recipients[email] = false recipients[email] = false
} }
} }
} }
for _, id := range ch.mentionIDs { for _, id := range mentionIDs {
if email := c.resolveEmail(ctx, id); email != "" { if email := c.resolveEmail(ctx, id); email != "" {
recipients[email] = true // 멘션이 더 구체적 — 라벨 우선 recipients[email] = true
} }
} }
var notes []notify.Notification var notes []notify.Notification
for email, mentioned := range recipients { for email, mentioned := range recipients {
if authorEmails[email] { if authorEmails[email] {
continue // 작성자 본인에게는 보내지 않음 continue
} }
header := "🗒 Notion — " + label tag := "[Notion]"
text := fmt.Sprintf("[Notion] %s — %s", label, title) text := fmt.Sprintf("[Notion] %s — %s", label, title)
if mentioned { if mentioned {
header = "🔔 Notion 멘션 — " + label tag = "[멘션]"
text = fmt.Sprintf("[Notion] 멘션 — %s", title) text = fmt.Sprintf("[멘션] %s — %s", label, title)
} }
header := tag + " " + label + " — " + headerTitle
notes = append(notes, notify.Notification{ notes = append(notes, notify.Notification{
Email: email, Email: email,
Text: text, Text: text,
Blocks: notify.SimpleBlocks(header, body2, context), Blocks: notify.SimpleBlocks(header, body, context),
}) })
} }
return notes, nil return notes
} }
// describeChange summarizes what changed (display lines + mentioned users) by event type. // --- diff helpers ---
func (c *Client) describeChange(ctx context.Context, ev *event, pg *page) change {
switch ev.Type { // diffLines formats one property change as labeled lines. List-type
case "page.properties_updated": // (people/multi_select)은 항목별 [추가]/[삭제], 그 외는 [변경] 이전 → 현재.
return change{lines: c.changedProperties(ctx, ev, pg)} // 변화가 없으면 빈 슬라이스.
case "page.content_updated": func diffLines(name, oldVal, newVal, ptype string) []string {
return c.changedBlocks(ctx, ev) if ptype == "people" || ptype == "multi_select" {
case "comment.created", "comment.updated": add, rem := listDiff(oldVal, newVal)
return c.commentText(ctx, ev) var lines []string
case "page.created": for _, a := range add {
return change{lines: []string{"• 새 페이지가 생성되었습니다."}} lines = append(lines, "[추가] "+name+": "+a)
case "page.deleted": }
return change{lines: []string{"• 페이지가 삭제(휴지통 이동)되었습니다."}} for _, r := range rem {
case "page.undeleted": lines = append(lines, "[삭제] "+name+": "+r)
return change{lines: []string{"• 페이지가 복원되었습니다."}} }
case "page.moved": return lines
return change{lines: []string{"• 페이지가 이동되었습니다."}} }
case "comment.deleted": if oldVal == newVal {
return change{lines: []string{"• 댓글이 삭제되었습니다."}} 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 change{} }
return added, removed
} }
// changedProperties maps updated property IDs to their name + new value. func splitList(s string) []string {
func (c *Client) changedProperties(ctx context.Context, ev *event, pg *page) []string { s = strings.TrimSpace(s)
if len(ev.Data.UpdatedProperties) == 0 { if s == "" {
return nil return nil
} }
nameByID := make(map[string]string, len(pg.Properties)) var out []string
propByID := make(map[string]property, len(pg.Properties)) for _, p := range strings.Split(s, ", ") {
for name, p := range pg.Properties { if p = strings.TrimSpace(p); p != "" {
nameByID[p.ID] = name out = append(out, p)
propByID[p.ID] = 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 var lines []string
for _, id := range ev.Data.UpdatedProperties { for _, name := range order {
name := nameByID[id] if v := props[name]; v != "" {
if name == "" { lines = append(lines, name+": "+truncRunes(v, valueRunes))
name = "(알 수 없는 속성)"
} }
val := c.renderValue(ctx, propByID[id])
if val == "" {
val = "(비어 있음)"
} }
lines = append(lines, fmt.Sprintf("• *%s*: %s", name, val)) return strings.Join(lines, "\n")
} }
return lines
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
} }
// renderValue formats a property's current value for display.
func (c *Client) renderValue(ctx context.Context, p property) string { func (c *Client) renderValue(ctx context.Context, p property) string {
switch p.Type { switch p.Type {
case "title": case "title":
@ -315,92 +539,42 @@ func (c *Client) renderValue(ctx context.Context, p property) string {
return "" return ""
} }
// changedBlocks fetches updated blocks and renders text + mentioned users. // blockInfo fetches a block: its text, @-mentioned users, and whether it still
func (c *Client) changedBlocks(ctx context.Context, ev *event) change { // exists (false = 삭제/휴지통 → 호출자가 마지막 텍스트를 스냅샷에서 가져감).
var ch change func (c *Client) blockInfo(ctx context.Context, id string) (text string, mentions []string, exists bool) {
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 var raw json.RawMessage
if err := c.get(ctx, "blocks/"+id, &raw); err != nil { if err := c.get(ctx, "blocks/"+id, &raw); err != nil {
return "", nil return "", nil, false
} }
var head struct { var head struct {
Type string `json:"type"` Type string `json:"type"`
Archived bool `json:"archived"`
InTrash bool `json:"in_trash"`
} }
if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" { if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" {
return "", nil return "", nil, false
}
if head.Archived || head.InTrash {
return "", nil, false
} }
var m map[string]json.RawMessage var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
return "", nil return "", nil, true
} }
var inner struct { var inner struct {
RichText []richText `json:"rich_text"` RichText []richText `json:"rich_text"`
} }
_ = json.Unmarshal(m[head.Type], &inner) // 타입별 키에만 rich_text 존재 _ = json.Unmarshal(m[head.Type], &inner) // 타입별 키에만 rich_text 존재
return strings.TrimSpace(plain(inner.RichText)), mentionUserIDs(inner.RichText) return strings.TrimSpace(plain(inner.RichText)), mentionUserIDs(inner.RichText), true
} }
// commentText fetches the page's comments and returns the one matching the event, // --- page / user helpers ---
// 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 titleLink(pg *page, title string) string {
func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, emails map[string]bool) { if pg.URL != "" {
emails = make(map[string]bool) return fmt.Sprintf("<%s|%s>", pg.URL, title)
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 return title
} }
func pageTitle(pg *page) string { func pageTitle(pg *page) string {
@ -414,15 +588,14 @@ func pageTitle(pg *page) string {
return "(제목 없음)" return "(제목 없음)"
} }
// assigneeIDs collects user IDs from all configured People properties // assigneeIDs collects user IDs from all configured People properties,
// (담당자/처리자/참조인원 등), de-duplicated across properties. // de-duplicated across properties.
func (c *Client) assigneeIDs(pg *page) []string { func (c *Client) assigneeIDs(pg *page) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
var ids []string var ids []string
for _, name := range c.assigneeProperties { for _, name := range c.assigneeProperties {
prop, ok := pg.Properties[name] prop, ok := pg.Properties[name]
if !ok || prop.Type != "people" { if !ok || prop.Type != "people" {
slog.Info("notion page has no people property", "property", name)
continue continue
} }
for _, p := range prop.People { for _, p := range prop.People {
@ -435,7 +608,24 @@ func (c *Client) assigneeIDs(pg *page) []string {
return ids return ids
} }
// resolveUser returns a user's display name, and email when the user is a person. // 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
}
func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) { func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) {
var u struct { var u struct {
Name string `json:"name"` Name string `json:"name"`
@ -463,6 +653,27 @@ func (c *Client) resolveName(ctx context.Context, userID string) string {
return name 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. // eventLabel maps a Notion event type to a short Korean label.
func eventLabel(t string) string { func eventLabel(t string) string {
switch t { switch t {

80
internal/notion/notion_test.go

@ -60,25 +60,73 @@ func TestRenderDate(t *testing.T) {
} }
} }
func TestChangedProperties(t *testing.T) { func TestDiffLines(t *testing.T) {
c := &Client{} cases := []struct {
pg := &page{Properties: map[string]property{ name, old, new, ptype string
"상태": {ID: "p1", Type: "status", Status: &selectOption{Name: "진행중"}}, want []string
"우선순위": {ID: "p2", Type: "select", Select: &selectOption{Name: "높음"}}, }{
"제목": {ID: "title", Type: "title", Title: []richText{{PlainText: "T"}}}, {"상태", "검토", "진행중", "status", []string{"[변경] 상태: 검토 → 진행중"}},
}} {"마감일", "", "2026-06-20", "date", []string{"[변경] 마감일: 2026-06-20 (신규)"}},
ev := &event{} {"상태", "완료", "", "status", []string{"[삭제] 상태: 완료"}},
ev.Data.UpdatedProperties = []string{"p1", "p2"} {"상태", "완료", "완료", "status", nil}, // 변화 없음
{"담당자", "김영운, 이철수", "김영운, 박지민", "people", []string{"[추가] 담당자: 박지민", "[삭제] 담당자: 이철수"}},
{"태그", "", "긴급", "multi_select", []string{"[추가] 태그: 긴급"}},
{"태그", "긴급, 배포", "긴급, 배포", "multi_select", nil}, // 동일 목록
}
for _, tc := range cases {
got := diffLines(tc.name, tc.old, tc.new, tc.ptype)
if len(got) != len(tc.want) {
t.Errorf("diffLines(%q,%q,%q,%q) = %v, want %v", tc.name, tc.old, tc.new, tc.ptype, got, tc.want)
continue
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("diffLines[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
}
}
lines := c.changedProperties(context.Background(), ev, pg) func TestListDiff(t *testing.T) {
if len(lines) != 2 { add, rem := listDiff("a, b, c", "b, c, d")
t.Fatalf("want 2 lines, got %d: %v", len(lines), lines) if len(add) != 1 || add[0] != "d" {
t.Errorf("added = %v, want [d]", add)
} }
if lines[0] != "• *상태*: 진행중" { if len(rem) != 1 || rem[0] != "a" {
t.Errorf("line0 = %q", lines[0]) t.Errorf("removed = %v, want [a]", rem)
} }
if lines[1] != "• *우선순위*: 높음" { }
t.Errorf("line1 = %q", lines[1])
func TestTruncRunes(t *testing.T) {
if got := truncRunes("짧은글", 10); got != "짧은글" {
t.Errorf("no-trunc = %q", got)
}
if got := truncRunes("가나다라마바사", 3); got != "가나다…" {
t.Errorf("trunc = %q, want 가나다…", got)
}
}
func TestSnapshotStoreRoundTrip(t *testing.T) {
path := t.TempDir() + "/snap.json"
s, err := NewSnapshotStore(path)
if err != nil {
t.Fatal(err)
}
s.Put("page1", snapshot{Title: "T", Props: map[string]string{"상태": "진행중"}})
// 다시 로드해도 유지되는지 (파일 영속화 확인)
s2, err := NewSnapshotStore(path)
if err != nil {
t.Fatal(err)
}
got, ok := s2.Get("page1")
if !ok || got.Props["상태"] != "진행중" {
t.Fatalf("reload = %+v ok=%v", got, ok)
}
s2.Delete("page1")
if _, ok := s2.Get("page1"); ok {
t.Errorf("expected page1 deleted")
} }
} }

89
internal/notion/snapshot.go

@ -0,0 +1,89 @@
package notion
import (
"encoding/json"
"log/slog"
"os"
"path/filepath"
"sync"
)
// snapshot is the last-known state of a page, used to diff the next event.
// 페이지당 1개만 유지(덮어쓰기) — 과거 이력을 누적하지 않는다.
type snapshot struct {
Title string `json:"title"`
Props map[string]string `json:"props"` // 속성명 → 렌더된 현재 값
Blocks map[string]string `json:"blocks,omitempty"` // 블록 ID → 텍스트 (편집된 블록만 점진 저장)
}
// SnapshotStore persists page snapshots to a JSON file (thread-safe).
// 비활성화하려면 path를 비우면 된다(메모리에만 유지, diff는 동작).
type SnapshotStore struct {
mu sync.Mutex
path string
data map[string]snapshot // pageID → snapshot
}
// NewSnapshotStore loads snapshots from path (missing file is fine).
func NewSnapshotStore(path string) (*SnapshotStore, error) {
s := &SnapshotStore{path: path, data: make(map[string]snapshot)}
if path == "" {
return s, nil
}
b, err := os.ReadFile(path)
switch {
case err == nil:
if e := json.Unmarshal(b, &s.data); e != nil {
return nil, e
}
case os.IsNotExist(err):
// 최초 실행: 빈 스토어로 시작
default:
return nil, err
}
return s, nil
}
func (s *SnapshotStore) Get(pageID string) (snapshot, bool) {
s.mu.Lock()
defer s.mu.Unlock()
snap, ok := s.data[pageID]
return snap, ok
}
func (s *SnapshotStore) Put(pageID string, snap snapshot) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[pageID] = snap
s.persist()
}
func (s *SnapshotStore) Delete(pageID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, pageID)
s.persist()
}
// persist writes the whole map atomically (tmp + rename). 호출자가 lock 보유.
func (s *SnapshotStore) persist() {
if s.path == "" {
return
}
if dir := filepath.Dir(s.path); dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0o755)
}
b, err := json.MarshalIndent(s.data, "", " ")
if err != nil {
slog.Warn("notion snapshot marshal failed", "err", err)
return
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
slog.Warn("notion snapshot write failed", "err", err)
return
}
if err := os.Rename(tmp, s.path); err != nil {
slog.Warn("notion snapshot rename failed", "err", err)
}
}

7
main.go

@ -31,7 +31,12 @@ func main() {
os.Exit(1) os.Exit(1)
} }
notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperties) notionSnapshots, err := notion.NewSnapshotStore(cfg.NotionSnapshotFile)
if err != nil {
slog.Warn("failed to load notion snapshot store — diff disabled", "file", cfg.NotionSnapshotFile, "err", err)
notionSnapshots = nil
}
notionClient := notion.New(cfg.NotionAPIToken, cfg.NotionAssigneeProperties, notionSnapshots)
srv := server.New(cfg, slackClient, store, notionClient) srv := server.New(cfg, slackClient, store, notionClient)

Loading…
Cancel
Save