|
|
|
@ -12,6 +12,7 @@ import ( |
|
|
|
"sort" |
|
|
|
"sort" |
|
|
|
"strconv" |
|
|
|
"strconv" |
|
|
|
"strings" |
|
|
|
"strings" |
|
|
|
|
|
|
|
"sync" |
|
|
|
"time" |
|
|
|
"time" |
|
|
|
|
|
|
|
|
|
|
|
"git/palnet/slack-notifier/internal/notify" |
|
|
|
"git/palnet/slack-notifier/internal/notify" |
|
|
|
@ -32,6 +33,17 @@ type Client struct { |
|
|
|
assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등)
|
|
|
|
assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등)
|
|
|
|
snap *SnapshotStore |
|
|
|
snap *SnapshotStore |
|
|
|
http *http.Client |
|
|
|
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 { |
|
|
|
func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client { |
|
|
|
@ -40,6 +52,7 @@ func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client |
|
|
|
assigneeProperties: assigneeProperties, |
|
|
|
assigneeProperties: assigneeProperties, |
|
|
|
snap: snap, |
|
|
|
snap: snap, |
|
|
|
http: &http.Client{Timeout: 10 * time.Second}, |
|
|
|
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, ", ") |
|
|
|
context = "by " + strings.Join(authorNames, ", ") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assigneeIDs := c.assigneeIDs(pg) |
|
|
|
recipients := make(map[string]bool) // email → mentioned?
|
|
|
|
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 email := c.resolveEmail(ctx, id); email != "" { |
|
|
|
if _, ok := recipients[email]; !ok { |
|
|
|
if _, ok := recipients[email]; !ok { |
|
|
|
recipients[email] = false |
|
|
|
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 |
|
|
|
var notes []notify.Notification |
|
|
|
for email, mentioned := range recipients { |
|
|
|
for email, mentioned := range recipients { |
|
|
|
if authorEmails[email] { |
|
|
|
if authorEmails[email] { |
|
|
|
@ -626,7 +652,20 @@ func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string, |
|
|
|
return names, emails |
|
|
|
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) { |
|
|
|
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 { |
|
|
|
var u struct { |
|
|
|
Name string `json:"name"` |
|
|
|
Name string `json:"name"` |
|
|
|
Type string `json:"type"` |
|
|
|
Type string `json:"type"` |
|
|
|
@ -635,11 +674,22 @@ func (c *Client) resolveUser(ctx context.Context, userID string) (name, email st |
|
|
|
} `json:"person"` |
|
|
|
} `json:"person"` |
|
|
|
} |
|
|
|
} |
|
|
|
if err := c.get(ctx, "users/"+userID, &u); err != nil { |
|
|
|
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 "", "" |
|
|
|
return "", "" |
|
|
|
} |
|
|
|
} |
|
|
|
if u.Type == "person" { |
|
|
|
if u.Type == "person" { |
|
|
|
email = u.Person.Email |
|
|
|
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 |
|
|
|
return u.Name, email |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|