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 { 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 { slog.Error("slack postMessage failed", "channel", channel, "err", err) return false } if !out.OK { slog.Error("slack postMessage failed", "channel", channel, "error", out.Error) return false } return true } // SendDM resolves email -> Slack user and DMs them. Falls back to fallbackChannel // if the email can't be resolved; if there's no fallback either, it is skipped. func (c *Client) SendDM(ctx context.Context, email, text string, blocks []notify.Block, fallbackChannel string) bool { if id, ok := c.LookupUserIDByEmail(ctx, email); ok { return c.PostMessage(ctx, id, text, blocks) } if fallbackChannel != "" { prefix := "(담당자 미지정) " if email != "" { prefix = fmt.Sprintf("(담당자 %s 매핑 실패) ", email) } slog.Info("email unresolved — routing to fallback channel", "email", email, "channel", fallbackChannel) return c.PostMessage(ctx, fallbackChannel, prefix+text, blocks) } slog.Info("no Slack recipient and no fallback channel — skipped", "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) }