You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
729 lines
20 KiB
729 lines
20 KiB
// Package notion maps Notion webhook events to DMs for a page's assignees, |
|
// summarizing what changed (이전→현재 diff) using a per-page snapshot store. |
|
package notion |
|
|
|
import ( |
|
"context" |
|
"encoding/json" |
|
"fmt" |
|
"io" |
|
"log/slog" |
|
"net/http" |
|
"sort" |
|
"strconv" |
|
"strings" |
|
"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 |
|
} |
|
|
|
func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client { |
|
return &Client{ |
|
token: token, |
|
assigneeProperties: assigneeProperties, |
|
snap: snap, |
|
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"` |
|
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, ", ") |
|
} |
|
|
|
recipients := make(map[string]bool) // email → mentioned? |
|
for _, id := range c.assigneeIDs(pg) { |
|
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 |
|
} |
|
} |
|
|
|
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 |
|
} |
|
|
|
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 |
|
} |
|
|
|
// --- 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) |
|
}
|
|
|