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