You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
3.4 KiB
138 lines
3.4 KiB
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) |
|
}
|
|
|