internal/output/slack/webhook.go

// 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
}