Skip to content

Commit 633b31d

Browse files
kitallisclaude
andcommitted
Add Slack threading support using Bot Token + Web API
When SLACK_BOT_TOKEN and SLACK_CHANNEL_ID are configured: - Uses Slack Web API (chat.postMessage) instead of webhook - Sends header+quotes as parent message - Sends each customer as a threaded reply Falls back to webhook if bot token not configured. Setup: 1. Create Slack app at api.slack.com/apps 2. Add chat:write scope under OAuth & Permissions 3. Install to workspace, copy Bot Token (xoxb-...) 4. Get channel ID (right-click channel > View channel details) 5. Create secrets: slack-bot-token, slack-channel-id 6. Invite bot to channel: /invite @YourAppName Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 38faf7b commit 633b31d

File tree

2 files changed

+101
-32
lines changed

2 files changed

+101
-32
lines changed

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,4 @@ jobs:
5454
--region europe-west3 \
5555
--project ${{ secrets.PROJECT_ID }} \
5656
--allow-unauthenticated \
57-
--set-secrets "SLACK_WEBHOOK_URL=fastspring-slack-webhook:latest,FASTSPRING_HMAC_SECRET=fastspring-hmac-secret:latest,FASTSPRING_API_USERNAME=fastspring-api-username:latest,FASTSPRING_API_PASSWORD=fastspring-api-password:latest"
57+
--set-secrets "SLACK_WEBHOOK_URL=fastspring-slack-webhook:latest,FASTSPRING_HMAC_SECRET=fastspring-hmac-secret:latest,FASTSPRING_API_USERNAME=fastspring-api-username:latest,FASTSPRING_API_PASSWORD=fastspring-api-password:latest,SLACK_BOT_TOKEN=slack-bot-token:latest,SLACK_CHANNEL_ID=slack-channel-id:latest"

main.go

Lines changed: 100 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,17 @@ type SubDigest struct {
191191
}
192192

193193
type SlackMessage struct {
194-
Text string `json:"text,omitempty"`
195-
Blocks []Block `json:"blocks,omitempty"`
194+
Text string `json:"text,omitempty"`
195+
Blocks []Block `json:"blocks,omitempty"`
196+
Channel string `json:"channel,omitempty"`
197+
ThreadTS string `json:"thread_ts,omitempty"`
198+
}
199+
200+
type SlackResponse struct {
201+
OK bool `json:"ok"`
202+
Error string `json:"error,omitempty"`
203+
TS string `json:"ts,omitempty"`
204+
Channel string `json:"channel,omitempty"`
196205
}
197206

198207
type Block struct {
@@ -995,14 +1004,7 @@ func digestHandler(w http.ResponseWriter, r *http.Request) {
9951004
}
9961005

9971006
func sendDigestToSlack(customers map[string]*CustomerDigest, order []string, quotes []QuoteAPIData) error {
998-
message := formatDigestMessage(customers, order, quotes)
999-
1000-
// Slack supports up to 40,000 chars, but for readability split at ~10k
1001-
if len(message.Text) <= 10000 {
1002-
return sendSlackNotification(message)
1003-
}
1004-
1005-
// If too long, send header + quotes first, then customers in batches
1007+
// Send header message first (this becomes the parent for threading)
10061008
header := fmt.Sprintf(":bar_chart: *Weekly Payment Digest* — %s\n\n*%d customers* with subscriptions",
10071009
time.Now().Format("Jan 2, 2006"), len(customers))
10081010

@@ -1011,35 +1013,55 @@ func sendDigestToSlack(customers map[string]*CustomerDigest, order []string, quo
10111013
header += "\n\n" + formatQuotesSection(quotes)
10121014
}
10131015

1014-
if err := sendSlackNotification(SlackMessage{Text: header}); err != nil {
1015-
return err
1016-
}
1017-
1018-
// Send customers in batches to avoid hitting limits
1019-
var batch strings.Builder
1020-
for i, accountID := range order {
1021-
cd := customers[accountID]
1022-
section := formatCustomerSection(cd)
1016+
parentTS, err := sendSlackMessage(SlackMessage{Text: header})
1017+
if err != nil {
1018+
return fmt.Errorf("failed to send header: %w", err)
1019+
}
1020+
1021+
// If no threading support (no bot token), fall back to simple messages
1022+
if parentTS == "" {
1023+
// Send customers in batches without threading
1024+
var batch strings.Builder
1025+
for i, accountID := range order {
1026+
cd := customers[accountID]
1027+
section := formatCustomerSection(cd)
1028+
1029+
if batch.Len()+len(section) > 8000 {
1030+
if err := sendSlackNotification(SlackMessage{Text: batch.String()}); err != nil {
1031+
log.Printf("Failed to send digest batch: %v", err)
1032+
}
1033+
batch.Reset()
1034+
}
10231035

1024-
if batch.Len()+len(section) > 8000 {
1025-
// Send current batch and start new one
1026-
if err := sendSlackNotification(SlackMessage{Text: batch.String()}); err != nil {
1027-
log.Printf("Failed to send digest batch: %v", err)
1036+
if i > 0 && batch.Len() > 0 {
1037+
batch.WriteString("\n\n")
10281038
}
1029-
batch.Reset()
1039+
batch.WriteString(section)
10301040
}
10311041

1032-
if i > 0 && batch.Len() > 0 {
1033-
batch.WriteString("\n\n")
1042+
if batch.Len() > 0 {
1043+
if err := sendSlackNotification(SlackMessage{Text: batch.String()}); err != nil {
1044+
log.Printf("Failed to send final digest batch: %v", err)
1045+
}
10341046
}
1035-
batch.WriteString(section)
1047+
return nil
10361048
}
10371049

1038-
// Send remaining batch
1039-
if batch.Len() > 0 {
1040-
if err := sendSlackNotification(SlackMessage{Text: batch.String()}); err != nil {
1041-
log.Printf("Failed to send final digest batch: %v", err)
1050+
// Send each customer as a threaded reply
1051+
for _, accountID := range order {
1052+
cd := customers[accountID]
1053+
section := formatCustomerSection(cd)
1054+
1055+
_, err := sendSlackMessage(SlackMessage{
1056+
Text: section,
1057+
ThreadTS: parentTS,
1058+
})
1059+
if err != nil {
1060+
log.Printf("Failed to send threaded message for %s: %v", accountID, err)
10421061
}
1062+
1063+
// Small delay to avoid rate limits
1064+
time.Sleep(100 * time.Millisecond)
10431065
}
10441066

10451067
return nil
@@ -1158,6 +1180,53 @@ func formatQuotesSection(quotes []QuoteAPIData) string {
11581180
return section
11591181
}
11601182

1183+
// sendSlackMessage sends a message and returns the timestamp (for threading)
1184+
func sendSlackMessage(message SlackMessage) (string, error) {
1185+
botToken := os.Getenv("SLACK_BOT_TOKEN")
1186+
channel := os.Getenv("SLACK_CHANNEL_ID")
1187+
1188+
// If bot token is configured, use Web API for threading support
1189+
if botToken != "" && channel != "" {
1190+
message.Channel = channel
1191+
1192+
payload, err := json.Marshal(message)
1193+
if err != nil {
1194+
return "", fmt.Errorf("failed to marshal slack message: %w", err)
1195+
}
1196+
1197+
req, err := http.NewRequest("POST", "https://slack.com/api/chat.postMessage", bytes.NewBuffer(payload))
1198+
if err != nil {
1199+
return "", fmt.Errorf("failed to create request: %w", err)
1200+
}
1201+
1202+
req.Header.Set("Content-Type", "application/json")
1203+
req.Header.Set("Authorization", "Bearer "+botToken)
1204+
1205+
client := &http.Client{Timeout: 10 * time.Second}
1206+
resp, err := client.Do(req)
1207+
if err != nil {
1208+
return "", fmt.Errorf("failed to send slack message: %w", err)
1209+
}
1210+
defer resp.Body.Close()
1211+
1212+
var slackResp SlackResponse
1213+
if err := json.NewDecoder(resp.Body).Decode(&slackResp); err != nil {
1214+
return "", fmt.Errorf("failed to decode slack response: %w", err)
1215+
}
1216+
1217+
if !slackResp.OK {
1218+
return "", fmt.Errorf("slack API error: %s", slackResp.Error)
1219+
}
1220+
1221+
log.Printf("Slack message sent successfully (ts: %s)", slackResp.TS)
1222+
return slackResp.TS, nil
1223+
}
1224+
1225+
// Fall back to webhook (no threading support)
1226+
err := sendSlackNotification(message)
1227+
return "", err
1228+
}
1229+
11611230
func sendSlackNotification(message SlackMessage) error {
11621231
webhookURL := os.Getenv("SLACK_WEBHOOK_URL")
11631232
if webhookURL == "" {

0 commit comments

Comments
 (0)