package slack import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) type WebhookClient struct { URL string Timeout time.Duration } type block struct { Type string `json:"type"` Text *blockText `json:"text,omitempty"` } type blockText struct { Type string `json:"type"` Text string `json:"text"` } type payload struct { // Slackはblocksがあってもtextを求めるケースがあるので fallback 用に入れておく Text string `json:"text,omitempty"` Blocks []block `json:"blocks,omitempty"` } func (c WebhookClient) Send(title, text string) error { if c.URL == "" { return fmt.Errorf("webhook URL is empty") } if c.Timeout <= 0 { c.Timeout = 10 * time.Second } p := makePayload(title, text) body, err := json.Marshal(p) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") res, err := (&http.Client{Timeout: c.Timeout}).Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode < 200 || res.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(res.Body, 2048)) return fmt.Errorf("slack webhook failed: status=%d body=%s", res.StatusCode, string(bytes.TrimSpace(b))) } return nil } func makePayload(title, text string) payload { title = strings.TrimSpace(title) text = strings.TrimSpace(text) if title == "" { // blocks なしで普通に送る return payload{Text: text} } // blocks で「太字タイトル + 本文」 blocks := []block{ { Type: "section", Text: &blockText{ Type: "mrkdwn", Text: "*" + escapeMrkdwn(title) + "*", }, }, { Type: "section", Text: &blockText{ Type: "mrkdwn", Text: escapeMrkdwn(text), }, }, } // fallback text はtitle + newline + body にしておく return payload{ Text: title + "\n" + text, Blocks: blocks, } } // 最小限のエスケープ( _ ` などはそのまま使いたい人もいるので控えめ) // 必要なら後で強化できます。 func escapeMrkdwn(s string) string { // Slack mrkdwnは基本そのまま送れるので、ここは今は何もしない方が自然です。 // 将来的に必要になったらここで置換します。 return s }