package slack import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "sync" "time" "git/palnet/slack-notifier/internal/notify" ) const apiBase = "https://slack.com/api" // Client is a minimal Slack Web API client: resolve users by email and DM them. type Client struct { token string http *http.Client mu sync.Mutex cache map[string]string // email -> user id ("" = looked up, not found) } func New(token string) *Client { return &Client{ token: token, http: &http.Client{Timeout: 10 * time.Second}, cache: make(map[string]string), } } func (c *Client) LookupUserIDByEmail(ctx context.Context, email string) (string, bool) { if email == "" { return "", false } c.mu.Lock() if id, ok := c.cache[email]; ok { c.mu.Unlock() return id, id != "" } c.mu.Unlock() endpoint := apiBase + "/users.lookupByEmail?" + url.Values{"email": {email}}.Encode() req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req.Header.Set("Authorization", "Bearer "+c.token) var out struct { OK bool `json:"ok"` Error string `json:"error"` User struct { ID string `json:"id"` } `json:"user"` } if err := c.do(req, &out); err != nil { slog.Warn("slack lookup failed", "email", email, "err", err) return "", false } id := "" if out.OK { id = out.User.ID } else { slog.Warn("slack user lookup failed", "email", email, "error", out.Error) } c.mu.Lock() c.cache[email] = id c.mu.Unlock() return id, id != "" } func (c *Client) PostMessage(ctx context.Context, channel, text string, blocks []notify.Block) bool { if err := c.post(ctx, channel, text, blocks); err != nil { slog.Error("slack postMessage failed", "channel", channel, "err", err) return false } return true } // PostAlert는 PostMessage와 같지만 실패해도 slog로 기록하지 않는다. // 에러 알림 핸들러 전용 — slog.Error를 부르면 알림이 다시 트리거되어 무한루프가 된다. func (c *Client) PostAlert(ctx context.Context, channel, text string, blocks []notify.Block) error { return c.post(ctx, channel, text, blocks) } func (c *Client) post(ctx context.Context, channel, text string, blocks []notify.Block) error { payload := map[string]any{"channel": channel} if text != "" { payload["text"] = text } if len(blocks) > 0 { payload["blocks"] = blocks } body, _ := json.Marshal(payload) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/chat.postMessage", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json; charset=utf-8") var out struct { OK bool `json:"ok"` Error string `json:"error"` } if err := c.do(req, &out); err != nil { return err } if !out.OK { return fmt.Errorf("slack api error: %s", out.Error) } return nil } // SendDM resolves email -> Slack user and DMs them. 담당자를 해석하지 못하면 // 채널로 보내지 않고 로그만 남기고 skip한다(브로드캐스트는 별도 경로에서 처리). func (c *Client) SendDM(ctx context.Context, email, text string, blocks []notify.Block) bool { if id, ok := c.LookupUserIDByEmail(ctx, email); ok { return c.PostMessage(ctx, id, text, blocks) } slog.Info("email unresolved — DM skipped (logged only)", "email", email) return false } func (c *Client) do(req *http.Request, out any) error { 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 } return json.Unmarshal(data, out) }