|
|
|
@ -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") |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func truncRunes(s string, n int) string { |
|
|
|
|
|
|
|
r := []rune(strings.TrimSpace(s)) |
|
|
|
|
|
|
|
if len(r) <= n { |
|
|
|
|
|
|
|
return string(r) |
|
|
|
} |
|
|
|
} |
|
|
|
return lines |
|
|
|
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 { |
|
|
|
|