|
|
// Package notion maps Notion webhook events to DMs for a page's assignees. |
|
|
package notion |
|
|
|
|
|
import ( |
|
|
"context" |
|
|
"encoding/json" |
|
|
"fmt" |
|
|
"io" |
|
|
"log/slog" |
|
|
"net/http" |
|
|
"strconv" |
|
|
"strings" |
|
|
"time" |
|
|
|
|
|
"git/palnet/slack-notifier/internal/notify" |
|
|
) |
|
|
|
|
|
const ( |
|
|
apiBase = "https://api.notion.com/v1" |
|
|
version = "2022-06-28" |
|
|
|
|
|
// 본문 변경 시 DM에 표시할 블록 수 상한 (이벤트가 집계돼 다수일 수 있음). |
|
|
maxChangedBlocks = 5 |
|
|
) |
|
|
|
|
|
// Client calls the Notion API to resolve a page's assignees into emails. |
|
|
type Client struct { |
|
|
token string |
|
|
assigneeProperties []string // 알림 대상이 되는 People 속성들 (담당자/처리자/참조인원 등) |
|
|
http *http.Client |
|
|
} |
|
|
|
|
|
func New(token string, assigneeProperties []string) *Client { |
|
|
return &Client{ |
|
|
token: token, |
|
|
assigneeProperties: assigneeProperties, |
|
|
http: &http.Client{Timeout: 10 * time.Second}, |
|
|
} |
|
|
} |
|
|
|
|
|
// 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"` // "text" | "mention" | ... |
|
|
Mention *struct { |
|
|
Type string `json:"type"` // "user" | "page" | "date" | ... |
|
|
User ref `json:"user"` |
|
|
} `json:"mention"` |
|
|
} |
|
|
|
|
|
// plain joins rich_text into a single string. plain_text already renders |
|
|
// @user 멘션을 표시 이름으로 포함하므로 표시용으로는 별도 멘션 처리가 필요 없다. |
|
|
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 |
|
|
} |
|
|
|
|
|
// change summarizes one event: display lines plus any @-mentioned user IDs. |
|
|
type change struct { |
|
|
lines []string |
|
|
mentionIDs []string |
|
|
} |
|
|
|
|
|
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 assignees, |
|
|
// enriched with a human-readable 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 |
|
|
} |
|
|
|
|
|
// 대상 페이지 결정: page.* 는 entity, comment.* 는 data.page_id. |
|
|
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 |
|
|
} |
|
|
|
|
|
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 := title |
|
|
if pg.URL != "" { |
|
|
body2 = fmt.Sprintf("<%s|%s>", pg.URL, title) |
|
|
} |
|
|
|
|
|
label := eventLabel(ev.Type) |
|
|
ch := c.describeChange(ctx, &ev, &pg) |
|
|
if len(ch.lines) > 0 { |
|
|
body2 += "\n\n*변경 내용*\n" + strings.Join(ch.lines, "\n") |
|
|
} |
|
|
|
|
|
// 작성자(이 변경/댓글을 일으킨 사람): 표시용 이름 + 자기 알림 제외용 이메일. |
|
|
authorNames, authorEmails := c.resolveAuthors(ctx, &ev) |
|
|
context := "" |
|
|
if len(authorNames) > 0 { |
|
|
context = "작성자: " + strings.Join(authorNames, ", ") |
|
|
} |
|
|
|
|
|
// 수신자: 담당자(People 속성) ∪ 멘션된 사람. 값 true = 멘션으로 받는 사람. |
|
|
recipients := make(map[string]bool) |
|
|
for _, id := range c.assigneeIDs(&pg) { |
|
|
if email := c.resolveEmail(ctx, id); email != "" { |
|
|
if _, ok := recipients[email]; !ok { |
|
|
recipients[email] = false |
|
|
} |
|
|
} |
|
|
} |
|
|
for _, id := range ch.mentionIDs { |
|
|
if email := c.resolveEmail(ctx, id); email != "" { |
|
|
recipients[email] = true // 멘션이 더 구체적 — 라벨 우선 |
|
|
} |
|
|
} |
|
|
|
|
|
var notes []notify.Notification |
|
|
for email, mentioned := range recipients { |
|
|
if authorEmails[email] { |
|
|
continue // 작성자 본인에게는 보내지 않음 |
|
|
} |
|
|
header := "🗒️ Notion — " + label |
|
|
text := fmt.Sprintf("[Notion] %s — %s", label, title) |
|
|
if mentioned { |
|
|
header = "🔔 Notion 멘션 — " + label |
|
|
text = fmt.Sprintf("[Notion] 멘션 — %s", title) |
|
|
} |
|
|
notes = append(notes, notify.Notification{ |
|
|
Email: email, |
|
|
Text: text, |
|
|
Blocks: notify.SimpleBlocks(header, body2, context), |
|
|
}) |
|
|
} |
|
|
return notes, nil |
|
|
} |
|
|
|
|
|
// describeChange summarizes what changed (display lines + mentioned users) by event type. |
|
|
func (c *Client) describeChange(ctx context.Context, ev *event, pg *page) change { |
|
|
switch ev.Type { |
|
|
case "page.properties_updated": |
|
|
return change{lines: c.changedProperties(ctx, ev, pg)} |
|
|
case "page.content_updated": |
|
|
return c.changedBlocks(ctx, ev) |
|
|
case "comment.created", "comment.updated": |
|
|
return c.commentText(ctx, ev) |
|
|
case "page.created": |
|
|
return change{lines: []string{"• 새 페이지가 생성되었습니다."}} |
|
|
case "page.deleted": |
|
|
return change{lines: []string{"• 페이지가 삭제(휴지통 이동)되었습니다."}} |
|
|
case "page.undeleted": |
|
|
return change{lines: []string{"• 페이지가 복원되었습니다."}} |
|
|
case "page.moved": |
|
|
return change{lines: []string{"• 페이지가 이동되었습니다."}} |
|
|
case "comment.deleted": |
|
|
return change{lines: []string{"• 댓글이 삭제되었습니다."}} |
|
|
} |
|
|
return change{} |
|
|
} |
|
|
|
|
|
// changedProperties maps updated property IDs to their name + new value. |
|
|
func (c *Client) changedProperties(ctx context.Context, ev *event, pg *page) []string { |
|
|
if len(ev.Data.UpdatedProperties) == 0 { |
|
|
return nil |
|
|
} |
|
|
nameByID := make(map[string]string, len(pg.Properties)) |
|
|
propByID := make(map[string]property, len(pg.Properties)) |
|
|
for name, p := range pg.Properties { |
|
|
nameByID[p.ID] = name |
|
|
propByID[p.ID] = p |
|
|
} |
|
|
var lines []string |
|
|
for _, id := range ev.Data.UpdatedProperties { |
|
|
name := nameByID[id] |
|
|
if name == "" { |
|
|
name = "(알 수 없는 속성)" |
|
|
} |
|
|
val := c.renderValue(ctx, propByID[id]) |
|
|
if val == "" { |
|
|
val = "(비어 있음)" |
|
|
} |
|
|
lines = append(lines, fmt.Sprintf("• *%s*: %s", name, val)) |
|
|
} |
|
|
return lines |
|
|
} |
|
|
|
|
|
// renderValue formats a property's current value for display. |
|
|
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 "" |
|
|
} |
|
|
|
|
|
// changedBlocks fetches updated blocks and renders text + mentioned users. |
|
|
func (c *Client) changedBlocks(ctx context.Context, ev *event) change { |
|
|
var ch change |
|
|
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 |
|
|
if err := c.get(ctx, "blocks/"+id, &raw); err != nil { |
|
|
return "", nil |
|
|
} |
|
|
var head struct { |
|
|
Type string `json:"type"` |
|
|
} |
|
|
if err := json.Unmarshal(raw, &head); err != nil || head.Type == "" { |
|
|
return "", nil |
|
|
} |
|
|
var m map[string]json.RawMessage |
|
|
if err := json.Unmarshal(raw, &m); err != nil { |
|
|
return "", nil |
|
|
} |
|
|
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) |
|
|
} |
|
|
|
|
|
// commentText fetches the page's comments and returns the one matching the event, |
|
|
// 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 (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 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" { |
|
|
slog.Info("notion page has no people property", "property", name) |
|
|
continue |
|
|
} |
|
|
for _, p := range prop.People { |
|
|
if p.ID != "" && !seen[p.ID] { |
|
|
seen[p.ID] = true |
|
|
ids = append(ids, p.ID) |
|
|
} |
|
|
} |
|
|
} |
|
|
return ids |
|
|
} |
|
|
|
|
|
// resolveUser returns a user's display name, and email when the user is a person. |
|
|
func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) { |
|
|
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 { |
|
|
return "", "" |
|
|
} |
|
|
if u.Type == "person" { |
|
|
email = u.Person.Email |
|
|
} |
|
|
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 |
|
|
} |
|
|
|
|
|
// 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) |
|
|
}
|
|
|
|