feat(notify): post order vents into per-order slack threads - made by claude
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-05-15 12:25:45 +02:00
parent 2966474ff5
commit e43040f702
4 changed files with 118 additions and 67 deletions

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
@@ -98,16 +99,17 @@ func (h *SlackHandler) dispatch(action slackAction, p slackInteractivePayload) {
case "buy_label":
h.handleBuyLabel(action.Value, p)
default:
notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf("Unknown action: %s", action.ActionID))
slog.Warn("slack interactivity: unknown action", "action_id", action.ActionID)
}
}
func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePayload) {
orderID, err := strconv.ParseUint(orderIDStr, 10, 32)
orderID64, err := strconv.ParseUint(orderIDStr, 10, 32)
if err != nil {
notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf(":x: Invalid order id `%s`", orderIDStr))
slog.Warn("slack buy_label: invalid order id", "value", orderIDStr)
return
}
orderID := uint(orderID64)
url := fmt.Sprintf("http://localhost:%s/api/v1/orders/%d/label", h.apiPort, orderID)
req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, url, nil)
@@ -115,7 +117,7 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
resp, err := h.httpClient.Do(req)
if err != nil {
notify.SlackPostToResponseURL(p.ResponseURL, fmt.Sprintf(":x: Label purchase failed: %v", err))
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: %v", err))
return
}
defer resp.Body.Close()
@@ -125,10 +127,10 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
TrackingNumber string `json:"tracking_number"`
Carrier string `json:"carrier"`
LabelURL string `json:"label_url"`
CostCents int `json:"cost_cents"`
CostCents int `json:"cost_cents"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
@@ -136,20 +138,17 @@ func (h *SlackHandler) handleBuyLabel(orderIDStr string, p slackInteractivePaylo
_ = json.Unmarshal(bodyBytes, &result)
if resp.StatusCode >= 300 {
msg := fmt.Sprintf(":x: Order #%d label failed: `%s` %s", orderID, result.Error.Code, result.Error.Message)
notify.SlackPostToResponseURL(p.ResponseURL, msg)
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(":x: Label purchase failed: `%s` %s", result.Error.Code, result.Error.Message))
return
}
msg := fmt.Sprintf(
":white_check_mark: Order #%d labeled — %s `%s` ($%.2f). <%s|Download label PDF>",
orderID,
notify.SlackPostToOrderThread(orderID, fmt.Sprintf(
":white_check_mark: Labeled — %s `%s` ($%.2f). <%s|Download label PDF>",
result.Data.Carrier,
result.Data.TrackingNumber,
float64(result.Data.CostCents)/100,
result.Data.LabelURL,
)
notify.SlackPostToResponseURL(p.ResponseURL, msg)
))
}
func (h *SlackHandler) verifySignature(r *http.Request, body []byte) bool {

View File

@@ -347,6 +347,7 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
if previousTrackingStatus != newStatus {
audit.TrackingUpdated(order.ID, order.Carrier, payload.Data.TrackingNumber, string(newStatus))
go notify.SlackPostToOrderThread(order.ID, formatTrackingUpdate(newStatus, payload.Data.TrackingStatus.StatusDetails))
}
if deliveredTransition {
audit.OrderDelivered(order.ID, payload.Data.TrackingNumber)
@@ -362,6 +363,28 @@ func (h *WebhookHandler) HandleShippo(w http.ResponseWriter, r *http.Request) {
})
}
func formatTrackingUpdate(status models.TrackingStatus, details string) string {
var icon, label string
switch status {
case models.TrackingStatusPreTransit:
icon, label = ":label:", "Label created"
case models.TrackingStatusTransit:
icon, label = ":truck:", "In transit"
case models.TrackingStatusDelivered:
icon, label = ":white_check_mark:", "Delivered"
case models.TrackingStatusReturned:
icon, label = ":leftwards_arrow_with_hook:", "Returned to sender"
case models.TrackingStatusFailure:
icon, label = ":warning:", "Delivery exception"
default:
icon, label = ":package:", string(status)
}
if details != "" {
return fmt.Sprintf("%s *%s* — %s", icon, label, details)
}
return fmt.Sprintf("%s *%s*", icon, label)
}
func mapShippoStatus(s string) (models.TrackingStatus, bool) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "UNKNOWN":

View File

@@ -60,8 +60,11 @@ type Order struct {
// Shippo label shit, populated by the PurchaseLabel handler
ShippoTransactionID *string `gorm:"size:255;uniqueIndex" json:"-"`
LabelURL string `gorm:"size:512" json:"-"`
LabelCostCents int `json:"-"`
LabelURL string `gorm:"size:512" json:"-"`
LabelCostCents int `json:"-"`
// Slack parent message ts for this order's thread (NULL if Slack notify disabled).
SlackThreadTS *string `gorm:"size:64" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`

View File

@@ -9,6 +9,7 @@ import (
"os"
"time"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
@@ -18,26 +19,37 @@ func dollars(cents int) string {
return fmt.Sprintf("$%.2f", float64(cents)/100)
}
// SlackOrderPaid posts a receipt-style Block Kit message with item lines,
// totals, shipping address, and a "Buy Label" button. Caller must preload
// order.Items before calling. 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
// 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 resp.Body.Close()
fallback := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total))
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 buildReceiptBlocks(order *models.Order) []map[string]any {
blocks := []map[string]any{
{
"type": "header",
"text": map[string]any{"type": "plain_text", "text": fallback, "emoji": true},
},
{"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{
@@ -45,7 +57,6 @@ func SlackOrderPaid(order *models.Order) {
"text": map[string]any{"type": "mrkdwn", "text": line},
})
}
blocks = append(blocks,
map[string]any{"type": "divider"},
map[string]any{
@@ -82,51 +93,66 @@ func SlackOrderPaid(order *models.Order) {
},
},
)
body, _ := json.Marshal(map[string]any{
"channel": channel,
"text": fallback,
"blocks": blocks,
})
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 notify failed", "error", err, "order_id", order.ID)
return
}
defer resp.Body.Close()
var parsed struct {
Ok bool `json:"ok"`
Error string `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&parsed)
if !parsed.Ok {
slog.Warn("slack notify non-ok", "error", parsed.Error, "order_id", order.ID)
}
return blocks
}
func SlackPostToResponseURL(responseURL, text string) {
if responseURL == "" {
// 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
}
body, _ := json.Marshal(map[string]any{
"replace_original": false,
"text": text,
parentText := fmt.Sprintf(":coffee: Order #%d paid - %s", order.ID, dollars(order.Total))
ts, ok := postSlackMessage(token, map[string]any{
"channel": channel,
"text": parentText,
})
resp, err := slackClient.Post(responseURL, "application/json", bytes.NewReader(body))
if err != nil {
slog.Warn("slack response_url post failed", "error", err)
if !ok {
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
slog.Warn("slack response_url non-2xx", "status", resp.StatusCode)
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)
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": ts,
"text": parentText,
"blocks": buildReceiptBlocks(order),
})
}
// SlackPostToOrderThread posts a plain-text reply inside the order's thread.
// Looks up the thread ts from the database. Silent no-op if the order has no
// saved thread (Slack was disabled when it was created, or post failed earlier).
func SlackPostToOrderThread(orderID uint, text string) {
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 thread post", "order_id", orderID, "error", err)
return
}
if order.SlackThreadTS == nil || *order.SlackThreadTS == "" {
return
}
_, _ = postSlackMessage(token, map[string]any{
"channel": channel,
"thread_ts": *order.SlackThreadTS,
"text": text,
})
}
var _ = time.Second