gitea, notion webhook
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.
 
 
 
 
 

518 lines
14 KiB

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