diff --git a/internal/notion/notion.go b/internal/notion/notion.go index ab1b22f..4abbfe3 100644 --- a/internal/notion/notion.go +++ b/internal/notion/notion.go @@ -12,6 +12,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "git/palnet/slack-notifier/internal/notify" @@ -32,6 +33,17 @@ type Client struct { assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등) snap *SnapshotStore http *http.Client + + userMu sync.Mutex + userCache map[string]userInfo // userID → 조회 결과 (성공/실패 모두 캐시) +} + +// userInfo is the cached result of a users/{id} lookup. ok=false면 조회 실패라 +// 수신자 목록에서 제외한다(빈 이메일). +type userInfo struct { + name string + email string + ok bool } func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client { @@ -40,6 +52,7 @@ func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client assigneeProperties: assigneeProperties, snap: snap, http: &http.Client{Timeout: 10 * time.Second}, + userCache: make(map[string]userInfo), } } @@ -318,8 +331,9 @@ func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, header context = "by " + strings.Join(authorNames, ", ") } + assigneeIDs := c.assigneeIDs(pg) recipients := make(map[string]bool) // email → mentioned? - for _, id := range c.assigneeIDs(pg) { + for _, id := range assigneeIDs { if email := c.resolveEmail(ctx, id); email != "" { if _, ok := recipients[email]; !ok { recipients[email] = false @@ -332,6 +346,18 @@ func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, header } } + // 수신자 해석 결과를 남겨 "왜 DM이 안 갔는지"를 로그로 추적 가능하게 한다. + excluded := 0 + for email := range recipients { + if authorEmails[email] { + excluded++ + } + } + slog.Info("notion recipients resolved", + "event", ev.Type, "label", label, + "assigneeProps", len(assigneeIDs), "mentions", len(mentionIDs), + "recipients", len(recipients), "authorExcluded", excluded) + var notes []notify.Notification for email, mentioned := range recipients { if authorEmails[email] { @@ -626,7 +652,20 @@ func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, return names, emails } +// resolveUser looks up a user's name/email via users/{id}, memoizing the +// result (성공·실패 모두). 조회 실패 시 빈 이메일을 돌려주고 호출부는 수신자 +// 목록에서 제외한다. 같은 이벤트 안 중복 조회를 없애 레이트리밋을 피한다. func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) { + if userID == "" { + return "", "" + } + c.userMu.Lock() + if cached, ok := c.userCache[userID]; ok { + c.userMu.Unlock() + return cached.name, cached.email + } + c.userMu.Unlock() + var u struct { Name string `json:"name"` Type string `json:"type"` @@ -635,11 +674,22 @@ func (c *Client) resolveUser(ctx context.Context, userID string) (name, email st } `json:"person"` } if err := c.get(ctx, "users/"+userID, &u); err != nil { + // 실패도 캐시(이벤트 내 재시도 폭주 방지)하되, 경고는 남긴다. + slog.Warn("notion resolve user failed — 수신자에서 제외", "user", userID, "err", err) + c.userMu.Lock() + c.userCache[userID] = userInfo{ok: false} + c.userMu.Unlock() return "", "" } if u.Type == "person" { email = u.Person.Email } + if email == "" { + slog.Warn("notion user has no email — 수신자에서 제외", "user", userID, "name", u.Name, "type", u.Type) + } + c.userMu.Lock() + c.userCache[userID] = userInfo{name: u.Name, email: email, ok: true} + c.userMu.Unlock() return u.Name, email }