Files
terminalShop/pkg/notify/slack.go

328 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package notify provides a simple interface to send notifications to Slack.
package notify
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// slack client
var slackClient = &http.Client{Timeout: 5 * time.Second}
// dollars converts cents to dollars
func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
// postSlackMessage POSTs to chat.postMessage and returns (ts, ok). Logs on failure.
func postSlackMessage(token string, payload map[string]any) (string, bool) {
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := slackClient.Do(req)
if err != nil {
slog.Warn("slack post failed", "error", err)
return "", false
}
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
TS string `json:"ts"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&parsed)
if !parsed.Ok {
slog.Warn("slack post non-ok", "error", parsed.Error)
return "", false
}
return parsed.TS, true
}
func pinMessage(token, channel, ts string) bool {
return slackPinCall(token, "https://slack.com/api/pins.add", channel, ts, "already_pinned")
}
func unpinMessage(token, channel, ts string) bool {
return slackPinCall(token, "https://slack.com/api/pins.remove", channel, ts, "no_pin")
}
func slackPinCall(token, endpoint, channel, ts, benignErr string) bool {
body, _ := json.Marshal(map[string]any{"channel": channel, "timestamp": ts})
req, _ := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := slackClient.Do(req)
if err != nil {
slog.Warn("slack pin call failed", "endpoint", endpoint, "error", err)
return false
}
defer func() { _ = resp.Body.Close() }()
var parsed struct {
Ok bool `json:"ok"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&parsed)
if !parsed.Ok && parsed.Error != benignErr {
slog.Warn("slack pin call non-ok", "endpoint", endpoint, "error", parsed.Error)
return false
}
return true
}
func buildReceiptBlocks(order *models.Order) []map[string]any {
blocks := []map[string]any{
{"type": "divider"},
}
for _, item := range order.Items {
line := fmt.Sprintf("*%s* × %d\n%s", item.Name, item.Quantity, dollars(item.Price*item.Quantity))
blocks = append(blocks, map[string]any{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": line},
})
}
blocks = append(blocks,
map[string]any{"type": "divider"},
map[string]any{
"type": "section",
"fields": []map[string]any{
{"type": "mrkdwn", "text": "*Subtotal:*\n" + dollars(order.Subtotal)},
{"type": "mrkdwn", "text": "*Shipping:*\n" + dollars(order.ShippingCost)},
{"type": "mrkdwn", "text": "*Total:*\n" + dollars(order.Total)},
},
},
map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": fmt.Sprintf(":package: *Ship to:*\n%s\n%s\n%s, %s %s",
order.ShippingName,
order.ShippingStreet,
order.ShippingCity,
order.ShippingZip,
order.ShippingCountry,
),
},
},
map[string]any{
"type": "actions",
"elements": []map[string]any{
{
"type": "button",
"text": map[string]any{"type": "plain_text", "text": "Buy Label", "emoji": true},
"value": fmt.Sprintf("%d", order.ID),
"action_id": "buy_label",
"style": "primary",
},
},
},
)
return blocks
}
// SlackOrderPaid posts a compact parent message ("Order #N paid - $X.XX") to
// the orders channel, persists the resulting ts onto the order, and posts the
// full receipt as the first thread reply. All subsequent order events
// (label-buy, tracking updates) should be posted as further thread replies
// via SlackPostToOrderThread. No-op if SLACK_BOT_TOKEN or SLACK_CHANNEL unset.
func SlackOrderPaid(order *models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
parentText := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total))
ts, ok := postSlackMessage(token, map[string]any{
"channel": channel,
"text": parentText,
})
if !ok {
return
}
if err := database.GetDB().Model(&models.Order{}).Where("id = ?", order.ID).Update("slack_thread_ts", ts).Error; err != nil {
slog.Warn("failed to save slack thread ts", "order_id", order.ID, "error", err)
}
pinMessage(token, channel, ts)
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": ts,
"text": parentText,
"blocks": buildReceiptBlocks(order),
})
}
func SlackUnpinOrder(orderID uint) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for unpin", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
unpinMessage(token, channel, *order.SlackThreadTS)
}
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
// It looks up the thread ts from the database and returns an error when the
// reply cannot be posted.
func SlackPostToOrderThread(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, false)
}
// SlackPostToOrderThreadBroadcast posts a thread reply that is also surfaced
// to the channel (reply_broadcast=true) so channel members get a normal
// notification. Use for messages that need immediate operator attention.
func SlackPostToOrderThreadBroadcast(orderID uint, text string) error {
return slackPostToOrderThread(orderID, text, true)
}
func slackPostToOrderThread(orderID uint, text string, broadcast bool) error {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return fmt.Errorf("slack is not configured")
}
var order models.Order
if err := database.GetDB().Select("id", "slack_thread_ts").Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for thread post", "order_id", orderID, "error", err)
return fmt.Errorf("failed to look up order thread: %w", err)
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return fmt.Errorf("order has no slack thread")
}
payload := map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": text,
}
if broadcast {
payload["reply_broadcast"] = true
}
if _, ok := postSlackMessage(token, payload); !ok {
slog.Warn("failed to post slack thread reply", "order_id", orderID)
return fmt.Errorf("failed to post slack thread reply")
}
return nil
}
func SlackPostLabelPurchased(orderID uint, labelURL string, costCents int) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" {
return
}
var order models.Order
if err := database.GetDB().
Select("id", "carrier", "tracking_number", "tracking_url", "slack_thread_ts").
Where("id = ?", orderID).First(&order).Error; err != nil {
slog.Warn("failed to look up order for label-purchased slack post", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
headLine := fmt.Sprintf(":white_check_mark: Labeled - %s `%s` (%s). <%s|Download label PDF>", order.Carrier, order.TrackingNumber, dollars(costCents), labelURL)
blocks := []map[string]any{
{
"type": "section",
"text": map[string]any{"type": "mrkdwn", "text": headLine},
},
}
if strings.EqualFold(order.Carrier, "BRING") {
blocks = append(blocks, markStatusActionBlock(order.ID))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": headLine,
"blocks": blocks,
})
}
func markStatusActionBlock(orderID uint) map[string]any {
id := fmt.Sprintf("%d", orderID)
return map[string]any{
"type": "actions",
"elements": []map[string]any{
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Pre-Transit", "emoji": true}, "value": id, "action_id": "mark_pre_transit"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark In Transit", "emoji": true}, "value": id, "action_id": "mark_in_transit", "style": "primary"},
{"type": "button", "text": map[string]any{"type": "plain_text", "text": "Mark Delivered", "emoji": true}, "value": id, "action_id": "mark_delivered"},
},
}
}
func SlackUnshippedReminder(orders []models.Order) {
token := os.Getenv("SLACK_BOT_TOKEN")
channel := os.Getenv("SLACK_CHANNEL")
if token == "" || channel == "" || len(orders) == 0 {
return
}
workspace := os.Getenv("SLACK_WORKSPACE_SUBDOMAIN")
lines := []string{fmt.Sprintf(":hourglass_flowing_sand: %d order(s) awaiting label", len(orders))}
for _, order := range orders {
lines = append(lines, " "+unshippedOrderLine(order, channel, workspace))
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"text": strings.Join(lines, "\n"),
})
}
func unshippedOrderLine(order models.Order, channel, workspace string) string {
age := time.Since(order.CreatedAt).Round(time.Hour)
location := order.ShippingCountry
if order.ShippingCity != "" {
location = order.ShippingCity + ", " + order.ShippingCountry
}
label := fmt.Sprintf("Order #%d", order.ID)
if order.SlackThreadTS != nil && *order.SlackThreadTS != "" && workspace != "" {
ts := strings.ReplaceAll(*order.SlackThreadTS, ".", "")
permalink := fmt.Sprintf("https://%s.slack.com/archives/%s/p%s", workspace, channel, ts)
label = fmt.Sprintf("<%s|Order #%d>", permalink, order.ID)
}
tag := ""
if order.ShippingCountry == "NO" {
tag = " _(NO * Bring blocked)_"
}
return fmt.Sprintf("%s * %s old * %s * %s%s", label, age, dollars(order.Total), location, tag)
}