feat(notify): post order vents into per-order slack threads - made by claude
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user