|
|
// 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" |
|
|
"time" |
|
|
|
|
|
"git/palnet/slack-notifier/internal/notify" |
|
|
) |
|
|
|
|
|
const ( |
|
|
apiBase = "https://api.notion.com/v1" |
|
|
version = "2022-06-28" |
|
|
) |
|
|
|
|
|
// Client calls the Notion API to resolve a page's assignees into emails. |
|
|
type Client struct { |
|
|
token string |
|
|
assigneeProperty string |
|
|
http *http.Client |
|
|
} |
|
|
|
|
|
func New(token, assigneeProperty string) *Client { |
|
|
return &Client{ |
|
|
token: token, |
|
|
assigneeProperty: assigneeProperty, |
|
|
http: &http.Client{Timeout: 10 * time.Second}, |
|
|
} |
|
|
} |
|
|
|
|
|
type event struct { |
|
|
Type string `json:"type"` |
|
|
Entity struct { |
|
|
ID string `json:"id"` |
|
|
Type string `json:"type"` |
|
|
} `json:"entity"` |
|
|
} |
|
|
|
|
|
type property struct { |
|
|
Type string `json:"type"` |
|
|
Title []struct { |
|
|
PlainText string `json:"plain_text"` |
|
|
} `json:"title"` |
|
|
People []struct { |
|
|
ID string `json:"id"` |
|
|
} `json:"people"` |
|
|
} |
|
|
|
|
|
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. |
|
|
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 |
|
|
} |
|
|
if ev.Entity.Type != "page" || ev.Entity.ID == "" { |
|
|
slog.Info("notion event entity is not a page — ignored", "type", ev.Entity.Type) |
|
|
return nil, nil |
|
|
} |
|
|
|
|
|
var pg page |
|
|
if err := c.get(ctx, "pages/"+ev.Entity.ID, &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) |
|
|
} |
|
|
evType := ev.Type |
|
|
if evType == "" { |
|
|
evType = "변경" |
|
|
} |
|
|
|
|
|
var notes []notify.Notification |
|
|
for _, id := range c.assigneeIDs(&pg) { |
|
|
email := c.resolveEmail(ctx, id) |
|
|
if email == "" { |
|
|
slog.Info("notion user has no accessible email — skipped", "user_id", id) |
|
|
continue |
|
|
} |
|
|
notes = append(notes, notify.Notification{ |
|
|
Email: email, |
|
|
Text: fmt.Sprintf("[Notion] 담당 페이지가 업데이트되었습니다 (%s)", evType), |
|
|
Blocks: notify.SimpleBlocks( |
|
|
"🗒️ Notion 업데이트 — "+evType, body2, ""), |
|
|
}) |
|
|
} |
|
|
return notes, nil |
|
|
} |
|
|
|
|
|
func pageTitle(pg *page) string { |
|
|
for _, prop := range pg.Properties { |
|
|
if prop.Type == "title" { |
|
|
s := "" |
|
|
for _, t := range prop.Title { |
|
|
s += t.PlainText |
|
|
} |
|
|
if s != "" { |
|
|
return s |
|
|
} |
|
|
} |
|
|
} |
|
|
return "(제목 없음)" |
|
|
} |
|
|
|
|
|
func (c *Client) assigneeIDs(pg *page) []string { |
|
|
prop, ok := pg.Properties[c.assigneeProperty] |
|
|
if !ok || prop.Type != "people" { |
|
|
slog.Info("notion page has no people property", "property", c.assigneeProperty) |
|
|
return nil |
|
|
} |
|
|
ids := make([]string, 0, len(prop.People)) |
|
|
for _, p := range prop.People { |
|
|
if p.ID != "" { |
|
|
ids = append(ids, p.ID) |
|
|
} |
|
|
} |
|
|
return ids |
|
|
} |
|
|
|
|
|
func (c *Client) resolveEmail(ctx context.Context, userID string) string { |
|
|
var u struct { |
|
|
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" { |
|
|
return "" |
|
|
} |
|
|
return u.Person.Email |
|
|
} |
|
|
|
|
|
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) |
|
|
}
|
|
|
|