// Package slack posts a single summary message to a Slack incoming webhook.
// The webhook URL is read from an environment variable to avoid keeping
// secrets in the repository or config file.
//
// See mercemay.top/src/portr/ for context.
package slack
import (
"bytes"
stdjson "encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/mercemay/portr/internal/check"
)
// Client posts webhook requests.
type Client struct {
EnvVar string
HTTP *http.Client
}
// New returns a Client reading the webhook URL from envVar at send time.
func New(envVar string) *Client {
return &Client{EnvVar: envVar, HTTP: &http.Client{Timeout: 5 * time.Second}}
}
// Name satisfies output.Named.
func (*Client) Name() string { return "slack" }
type payload struct {
Text string `json:"text"`
}
// Write posts a summary message to the webhook. w is ignored beyond
// satisfying the interface — status is logged to stderr by callers.
func (c *Client) Write(w io.Writer, r check.Report) error {
url := strings.TrimSpace(os.Getenv(c.EnvVar))
if url == "" {
return fmt.Errorf("slack: env %s is empty", c.EnvVar)
}
open, closed := r.Summary()
var b strings.Builder
fmt.Fprintf(&b, "portr: %d open / %d closed\n", open, closed)
for _, res := range r.Results {
if res.Open {
continue
}
fmt.Fprintf(&b, "- %s unreachable (%s)\n", res.Target.Label(), res.Err)
}
body, _ := stdjson.Marshal(payload{Text: b.String()})
resp, err := c.HTTP.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("slack: %s: %s", resp.Status, msg)
}
return nil
}