Implementing Slack Slash Commands with Go and Google Cloud Functions
Slack Slash Commands offer a powerful way to extend your workspace with custom functionality. In this guide, we’ll implement a robust, secure Slash Command handler using Go and Google Cloud Functions to create a serverless integration that’s both cost-effective and scalable.
Implementing Slack Slash Commands with Go and Google Cloud Functions
Why Serverless for Slack Commands?
Slack Slash Commands are an ideal use case for serverless architecture:
- Sporadic Usage: Commands are typically invoked intermittently, making serverless “pay-for-what-you-use” pricing efficient
- Low Latency Requirements: Cloud Functions provide near-instant scaling for responsive command execution
- Simplified Operations: No server management overhead or continuous runtime costs
- Security Isolation: Each function operates in its own environment, minimizing potential attack surface
Google Cloud Functions provide an excellent platform for these requirements with minimal configuration and maintenance.
Setting Up Your Environment
Prerequisites
Before diving into implementation, ensure you have:
- A Google Cloud Platform (GCP) account with billing enabled
- The
gcloudCLI tool installed and configured - Go development environment (Go 1.19+)
- A Slack workspace with permission to create applications
Creating a Cloud Function
First, we need to create a new HTTP-triggered Cloud Function:
Navigate to Cloud Functions in the Google Cloud Console
Click Create Function
Configure the basics:
- Environment: 2nd gen
- Function name:
slack-command-handler - Region: Select a region close to your users
- Runtime: Go 1.19 (or newer)
- Entry point:
HandleSlackCommand(the function we’ll implement)
For HTTP settings:
- Authentication: Allow unauthenticated invocations
- Require HTTPS: Enabled (required for Slack)
Note the Trigger URL after creation - we’ll need this for Slack configuration
Creating Your Slack Application
Next, we’ll create a Slack application and configure a Slash Command:
Visit the Slack API portal and click Create New App
Choose From scratch
Provide an App Name and select your Workspace
Under Features in the sidebar, select Slash Commands
Click Create New Command and configure:
- Command: The slash command (e.g.,
/tools) - Request URL: The Cloud Function trigger URL from earlier
- Short Description: Brief explanation of what the command does
- Usage Hint: Optional format guidance (e.g.,
/tools [action] [parameters])
- Command: The slash command (e.g.,
Click Save
Under Basic Information, copy the Signing Secret - we’ll use this to verify requests
Install the app to your workspace by navigating to Install App and clicking Install to Workspace
Implementing the Command Handler
Now let’s implement our Go function to handle Slack commands securely. Create a new Go module:
mkdir slack-command-handler
cd slack-command-handler
go mod init github.com/yourusername/slack-command-handler
Project Structure
We’ll use a clean structure:
slack-command-handler/
├── cmd/
│ └── function.go # Cloud Function entry point
├── internal/
│ ├── handler/
│ │ └── handler.go # Command processing logic
│ ├── slack/
│ │ └── verify.go # Slack signature verification
│ └── commands/
│ └── commands.go # Individual command implementations
├── go.mod
└── go.sum
Step 1: Implement Request Verification
First, let’s create the verification logic to ensure requests actually come from Slack:
// internal/slack/verify.go
package slack
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/ioutil"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
// SlackSignatureHeader is the header name for the Slack signature
SlackSignatureHeader = "X-Slack-Signature"
// SlackRequestTimestampHeader is the header name for the request timestamp
SlackRequestTimestampHeader = "X-Slack-Request-Timestamp"
// MaxRequestAge is the maximum age of a request in seconds
MaxRequestAge = 300 // 5 minutes
)
var (
ErrMissingSignature = errors.New("missing Slack signature header")
ErrMissingTimestamp = errors.New("missing timestamp header")
ErrInvalidTimestamp = errors.New("invalid timestamp format")
ErrRequestTooOld = errors.New("request timestamp too old")
ErrInvalidSignature = errors.New("invalid request signature")
ErrSigningSecretEmpty = errors.New("slack signing secret not configured")
)
// VerifyRequest validates that a request actually came from Slack
func VerifyRequest(r *http.Request) error {
// Get signing secret from environment
signingSecret := os.Getenv("SLACK_SIGNING_SECRET")
if signingSecret == "" {
return ErrSigningSecretEmpty
}
// Get Slack signature from header
signature := r.Header.Get(SlackSignatureHeader)
if signature == "" {
return ErrMissingSignature
}
// Get request timestamp
ts := r.Header.Get(SlackRequestTimestampHeader)
if ts == "" {
return ErrMissingTimestamp
}
// Parse timestamp
tsInt, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidTimestamp, err)
}
// Check if request is too old (replay attack protection)
now := time.Now().Unix()
if now-tsInt > MaxRequestAge {
return ErrRequestTooOld
}
// Read body
body, err := ioutil.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("failed to read request body: %w", err)
}
// Important: Restore the body for further processing
r.Body = ioutil.NopCloser(strings.NewReader(string(body)))
// Create signature base string
baseString := fmt.Sprintf("v0:%s:%s", ts, string(body))
// Create HMAC signature
h := hmac.New(sha256.New, []byte(signingSecret))
h.Write([]byte(baseString))
calculatedSignature := "v0=" + hex.EncodeToString(h.Sum(nil))
// Verify signature with constant time comparison to prevent timing attacks
if !hmac.Equal([]byte(signature), []byte(calculatedSignature)) {
return ErrInvalidSignature
}
return nil
}
Step 2: Implement Command Handler
Next, we’ll implement the core handler logic:
// internal/handler/handler.go
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/yourusername/slack-command-handler/internal/commands"
"github.com/yourusername/slack-command-handler/internal/slack"
)
// SlackResponse represents a formatted response to Slack
type SlackResponse struct {
ResponseType string `json:"response_type,omitempty"` // "in_channel" or "ephemeral"
Text string `json:"text,omitempty"`
Blocks []any `json:"blocks,omitempty"`
}
// HandleSlackCommand processes an incoming slash command from Slack
func HandleSlackCommand(w http.ResponseWriter, r *http.Request) {
// Only allow POST requests
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Verify request is from Slack
if err := slack.VerifyRequest(r); err != nil {
fmt.Printf("Request verification failed: %v\n", err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse the form data
if err := r.ParseForm(); err != nil {
http.Error(w, "Error parsing form data", http.StatusBadRequest)
return
}
// Extract command data
command := r.FormValue("command")
text := r.FormValue("text")
userID := r.FormValue("user_id")
channelID := r.FormValue("channel_id")
// Log command usage (helpful for monitoring)
fmt.Printf("Command executed: %s %s by user %s in channel %s\n",
command, text, userID, channelID)
// Process the command
response, err := processCommand(command, text, userID, channelID)
if err != nil {
fmt.Printf("Error processing command: %v\n", err)
sendErrorResponse(w, "Sorry, there was an error processing your command")
return
}
// Send the response
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// processCommand routes commands to the appropriate handler
func processCommand(command, text, userID, channelID string) (*SlackResponse, error) {
// Parse arguments
args := parseArgs(text)
// Route to appropriate command handler
switch command {
case "/tools":
return commands.HandleToolsCommand(args, userID, channelID)
default:
return &SlackResponse{
ResponseType: "ephemeral",
Text: fmt.Sprintf("Unrecognized command: %s", command),
}, nil
}
}
// parseArgs splits command text into arguments, respecting quoted strings
func parseArgs(text string) []string {
if text == "" {
return []string{}
}
// Basic splitting for now - can be enhanced for quoted arguments
return strings.Fields(text)
}
// sendErrorResponse sends a formatted error message
func sendErrorResponse(w http.ResponseWriter, message string) {
response := SlackResponse{
ResponseType: "ephemeral",
Text: message,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
Step 3: Implement Command Logic
Now we’ll implement specific command functionality:
// internal/commands/commands.go
package commands
import (
"fmt"
"strings"
"time"
)
// SlackResponse represents a formatted response to Slack
type SlackResponse struct {
ResponseType string `json:"response_type,omitempty"` // "in_channel" or "ephemeral"
Text string `json:"text,omitempty"`
Blocks []any `json:"blocks,omitempty"`
}
// HandleToolsCommand processes the /tools command
func HandleToolsCommand(args []string, userID, channelID string) (*SlackResponse, error) {
// If no arguments provided, show help
if len(args) == 0 {
return showHelp(), nil
}
// Process subcommands
switch args[0] {
case "help":
return showHelp(), nil
case "time":
return showCurrentTime(), nil
case "echo":
return echoMessage(args[1:]), nil
default:
return &SlackResponse{
ResponseType: "ephemeral",
Text: fmt.Sprintf("Unknown subcommand: %s\nType `/tools help` for available commands.", args[0]),
}, nil
}
}
// showHelp displays available commands
func showHelp() *SlackResponse {
helpText := `*Available commands:*
• \`/tools help\` - Show this help message
• \`/tools time\` - Show current server time
• \`/tools echo [message]\` - Echo back your message`
return &SlackResponse{
ResponseType: "ephemeral",
Text: helpText,
}
}
// showCurrentTime returns the current server time
func showCurrentTime() *SlackResponse {
now := time.Now()
return &SlackResponse{
ResponseType: "ephemeral",
Text: fmt.Sprintf("Current server time is: %s", now.Format(time.RFC1123)),
}
}
// echoMessage echoes the provided message back to the user
func echoMessage(args []string) *SlackResponse {
if len(args) == 0 {
return &SlackResponse{
ResponseType: "ephemeral",
Text: "Please provide a message to echo.",
}
}
message := strings.Join(args, " ")
return &SlackResponse{
// Using in_channel makes the response visible to everyone
ResponseType: "in_channel",
Text: fmt.Sprintf("Echo: %s", message),
}
}
Step 4: Cloud Function Entry Point
Finally, we need the main function entry point:
// cmd/function.go
package cmd
import (
"net/http"
"github.com/yourusername/slack-command-handler/internal/handler"
)
// HandleSlackCommand is the Cloud Function entry point
func HandleSlackCommand(w http.ResponseWriter, r *http.Request) {
handler.HandleSlackCommand(w, r)
}
Deploying to Google Cloud Functions
Now that we have our implementation, let’s prepare for deployment:
- Create a
go.modfile at the project root:
module github.com/yourusername/slack-command-handler
go 1.19
- Deploy using gcloud CLI:
gcloud functions deploy slack-command-handler \
--gen2 \
--runtime=go119 \
--region=us-central1 \
--source=. \
--entry-point=HandleSlackCommand \
--trigger-http \
--allow-unauthenticated \
--set-env-vars SLACK_SIGNING_SECRET=your_signing_secret_here
Security Note: For production, use Secret Manager to store your signing secret rather than environment variables.
Advanced Patterns for Production Use
1. Enhanced Security
For production environments, consider these additional security measures:
// Set strict security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'none'")
// Use Secret Manager instead of environment variables
secretClient, err := secretmanager.NewClient(ctx)
if err != nil {
log.Fatalf("Failed to create Secret Manager client: %v", err)
}
// Access the secret
secretResp, err := secretClient.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: "projects/your-project/secrets/slack-signing-secret/versions/latest",
})
2. Structured Logging
Implement structured logging for better observability:
type LogEntry struct {
Severity string `json:"severity"`
Message string `json:"message"`
Command string `json:"command,omitempty"`
User string `json:"user,omitempty"`
Channel string `json:"channel,omitempty"`
Timestamp string `json:"timestamp"`
}
func logInfo(message, command, user, channel string) {
entry := LogEntry{
Severity: "INFO",
Message: message,
Command: command,
User: user,
Channel: channel,
Timestamp: time.Now().Format(time.RFC3339),
}
entryJSON, _ := json.Marshal(entry)
fmt.Println(string(entryJSON))
}
3. Interactive Responses
You can enhance your responses with interactive elements:
// Block Kit message with interactive components
blocks := []map[string]interface{}{
{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": "Please select an option:",
},
},
{
"type": "actions",
"elements": []map[string]interface{}{
{
"type": "button",
"text": map[string]interface{}{
"type": "plain_text",
"text": "Option 1",
},
"value": "option_1",
"action_id": "button_1",
},
{
"type": "button",
"text": map[string]interface{}{
"type": "plain_text",
"text": "Option 2",
},
"value": "option_2",
"action_id": "button_2",
},
},
},
}
return &SlackResponse{
ResponseType: "ephemeral",
Blocks: blocks,
}
4. Rate Limiting with Redis
For high-traffic commands, implement rate limiting:
func checkRateLimit(userID string) error {
ctx := context.Background()
client, err := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_ADDR"),
})
if err != nil {
return fmt.Errorf("failed to connect to Redis: %w", err)
}
defer client.Close()
key := fmt.Sprintf("rate_limit:%s", userID)
count, err := client.Incr(ctx, key).Result()
if err != nil {
return fmt.Errorf("rate limit check failed: %w", err)
}
// First request sets expiration
if count == 1 {
client.Expire(ctx, key, time.Minute)
}
if count > 5 {
return errors.New("rate limit exceeded")
}
return nil
}
Real-World Use Cases
1. Kubernetes Status Checker
Implement a command to check the status of Kubernetes resources:
func handleK8sStatus(args []string) (*SlackResponse, error) {
if len(args) < 1 {
return &SlackResponse{
ResponseType: "ephemeral",
Text: "Usage: `/tools k8s [namespace]`",
}, nil
}
namespace := args[0]
// Use the Kubernetes Go client to fetch pod status
config, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("failed to get cluster config: %w", err)
}
clientset, err := kubernetes.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create clientset: %w", err)
}
pods, err := clientset.CoreV1().Pods(namespace).List(context.Background(), metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list pods: %w", err)
}
// Format pod status as text
var statusText strings.Builder
statusText.WriteString(fmt.Sprintf("*Pods in %s namespace:*\n", namespace))
for _, pod := range pods.Items {
statusText.WriteString(fmt.Sprintf("• %s: %s\n", pod.Name, pod.Status.Phase))
}
return &SlackResponse{
ResponseType: "ephemeral",
Text: statusText.String(),
}, nil
}
2. Automated On-Call Rotation
Implement a command to check or update on-call schedules:
func handleOncall(args []string) (*SlackResponse, error) {
if len(args) < 1 {
return getCurrentOncall()
}
switch args[0] {
case "current":
return getCurrentOncall()
case "next":
return getNextOncall()
case "override":
if len(args) < 2 {
return &SlackResponse{
ResponseType: "ephemeral",
Text: "Usage: `/tools oncall override @username`",
}, nil
}
return setOncallOverride(args[1])
default:
return &SlackResponse{
ResponseType: "ephemeral",
Text: "Unknown oncall command. Use `current`, `next`, or `override`.",
}, nil
}
}
Monitoring and Observability
To ensure reliable operation, implement proper monitoring:
Set up Cloud Monitoring with alerting on:
- Error rates
- Function execution time
- Function invocation count
Add custom metrics for business logic:
// Record command usage metrics metricClient, err := monitoring.NewMetricClient(ctx) if err != nil { log.Printf("Failed to create monitoring client: %v", err) } else { err = metricClient.CreateTimeSeries(ctx, &monitoringpb.CreateTimeSeriesRequest{ Name: "projects/" + projectID, TimeSeries: []*monitoringpb.TimeSeries{ { Metric: &metricpb.Metric{ Type: "custom.googleapis.com/slack/command_usage", Labels: map[string]string{ "command": command, }, }, Points: []*monitoringpb.Point{ { Interval: &monitoringpb.TimeInterval{ EndTime: ×tamppb.Timestamp{ Seconds: time.Now().Unix(), }, }, Value: &monitoringpb.TypedValue{ Value: &monitoringpb.TypedValue_Int64Value{ Int64Value: 1, }, }, }, }, }, }, }) if err != nil { log.Printf("Failed to write metrics: %v", err) } }
Conclusion
Implementing Slack Slash Commands with Go and Google Cloud Functions provides a powerful, cost-effective way to extend your team’s Slack workspace with custom functionality. The serverless architecture ensures your commands scale automatically with usage while minimizing operational overhead.
By following the security best practices outlined in this guide, your command handler will safely authenticate requests from Slack and protect against common attack vectors. The modular design allows for easy extension as your command needs grow more complex.
For teams already using Google Cloud, this approach leverages your existing infrastructure while providing a clean separation between your Slack integration and other systems. The result is a robust, maintainable command handler that enhances team productivity without significant operational burden.
Remember to consistently monitor your function’s performance and cost to ensure it continues to meet your needs as usage patterns evolve.