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