landing page, Go and SMS notifications

MTS Exolve. Today I’ll tell you how to create a simple but effective call back form with SMS notifications. I’ll give an example for a scenario when a client leaves a request through a form, and a manager contacts him through the Callback API. After a successful conversation, the system automatically sends an SMS via SMS API confirming the agreements and next steps.

SMS notifications here play the role of a reliable channel for consolidating the results of the conversation and reminding of agreements. They do not require the Internet or installation of applications and work everywhere, even with a weak connection signal.

Moreover, the example will be without cumbersome frameworks – only Go and pure HTML with a pinch of JavaScript.

Why is this needed in 2024

Unexpectedly, call back forms are still relevant. And it's not about technology, but about people. It’s easier for a client to leave a number on the website than to call themselves or look for contacts in instant messengers.

Moreover, SMS notifications remain the most reliable notification method. No internet needed. No application installation required. It works everywhere, even where the connection is barely breathing.

What will we get in the end?

Let's make two components:

  • minimalistic landing page with a call request form;

  • Go server for processing requests and sending SMS via API Exsolve.

It sounds simple, but the devil, as always, is in the details.

Let's start with the frontend

Let's create a simple but modern landing page. No heavy frameworks – just HTML5, CSS and pure JavaScript.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Заказать звонок</title>
    <style>
        .callback-form {
            display: none;
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }

        .callback-form.active {
            display: block;
        }

        .overlay {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0,0,0,0.5);
        }

        .overlay.active {
            display: block;
        }
    </style>
</head>
<body>
    <button onclick="showForm()">Заказать звонок</button>

    <div class="overlay" id="overlay"></div>
    <div class="callback-form" id="callbackForm">
        <h2>Заказать обратный звонок</h2>
        <form id="phoneForm">
            <input type="text" id="name" placeholder="Ваше имя" required>
            <input type="tel" id="phone" placeholder="+7 (___) ___-__-__" required>
            <label>
                <input type="checkbox" required>
                Согласен с политикой конфиденциальности
            </label>
            <button type="submit">Отправить</button>
        </form>
    </div>

    <script>
        function showForm() {
            document.getElementById('overlay').classList.add('active');
            document.getElementById('callbackForm').classList.add('active');
        }

        document.getElementById('phoneForm').addEventListener('submit', async (e) => {
            e.preventDefault();
            
            const formData = {
                name: document.getElementById('name').value,
                phone: document.getElementById('phone').value
            };

            try {
                const response = await fetch('/api/callback', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                    },
                    body: JSON.stringify(formData)
                });

                if (response.ok) {
                    alert('Спасибо! Мы перезвоним вам в ближайшее время.');
                } else {
                    alert('Произошла ошибка. Попробуйте позже.');
                }
            } catch (error) {
                console.error('Error:', error);
                alert('Произошла ошибка. Попробуйте позже.');
            }
        });
    </script>
</body>

Standard HTML with JavaScript can do a lot. Position: fixed with transform is an ancient method for centering a modal window, working everywhere, from push-button Nokia phones to the latest iPhones. In this example I'm not using Bootstrap or Material UI because it can be done simply and reliably. Asynchronous sending via fetch eliminates the need to reload the page.

Writing a server in Go

Now let's move on to the server part. We will need:

  • processing incoming requests;

  • data validation;

  • integration with Exolve API;

  • spam protection.

Let's start with the basic structure of the project:

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "time"
)

type CallbackRequest struct {
    Name  string `json:"name"`
    Phone string `json:"phone"`
}

type ExolveConfig struct {
    ApiKey string
    From   string
    To     string
}

var (
    config ExolveConfig
    client *http.Client
)

func init() {
    // Загружаем конфигурацию
    config = ExolveConfig{
        ApiKey: os.Getenv("EXOLVE_API_KEY"),
        From:   os.Getenv("SMS_FROM"),
        To:     os.Getenv("SMS_TO"),
    }

    // Инициализируем HTTP-клиент
    client = &http.Client{
        Timeout: time.Second * 10,
    }
}
type ExolveResponse struct {
    CallID string `json:"call_id"`
}

// Создаем структуру для отправки SMS через Exolve API
func sendSMS(phone string, name string) error {
    smsBody := struct {
        Number      string `json:"number"`      // Отправитель 
        Destination string `json:"destination"` // Получатель
        Text        string `json:"text"`        // Текст сообщения
    }{
        Number:      config.From,
        Destination: phone,  
        Text:        fmt.Sprintf("Здравствуйте, %s! Мы получили ваш запрос на обратный звонок и свяжемся с вами в ближайшее время.", name),
    }

    jsonData, err := json.Marshal(smsBody)
    if err != nil {
        return fmt.Errorf("ошибка при формировании SMS: %v", err)
    }

    req, err := http.NewRequest("POST", "https://api.exolve.ru/messaging/v1/SendSMS", bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("ошибка при создании запроса: %v", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("ошибка при отправке SMS: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
    }

    return nil
}

// Обработчик для API обратного звонка
func handleCallback(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Метод не поддерживается", http.StatusMethodNotAllowed)
        return
    }

    var request CallbackRequest
    if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
        http.Error(w, "Ошибка при разборе запроса", http.StatusBadRequest)
        return
    }

    // Валидация номера телефона
    if !validatePhone(request.Phone) {
        http.Error(w, "Некорректный номер телефона", http.StatusBadRequest)
        return
    }

    // Проверка на спам через Redis или другое хранилище
    if isSpamRequest(request.Phone) {
        http.Error(w, "Слишком много запросов", http.StatusTooManyRequests)
        return
    }

    // Отправляем SMS
    if err := sendSMS(request.Phone, request.Name); err != nil {
        log.Printf("Ошибка при отправке SMS: %v", err)
        http.Error(w, "Внутренняя ошибка сервера", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{
        "status": "success",
        "message": "Заявка принята",
    })
}

func main() {
    // Настраиваем CORS
    corsMiddleware := cors.New(cors.Options{
        AllowedOrigins: []string{"*"}, // В продакшне заменить на конкретные домены
        AllowedMethods: []string{"GET", "POST", "OPTIONS"},
        AllowedHeaders: []string{"Content-Type", "Authorization"},
    })

    // Настройка маршрутов
    mux := http.NewServeMux()
    mux.HandleFunc("/api/callback", handleCallback)

    // Оборачиваем наш мультиплексор в CORS middleware
    handler := corsMiddleware.Handler(mux)

    // Запуск сервера
    log.Printf("Сервер запущен на порту :8080")
    if err := http.ListenAndServe(":8080", handler); err != nil {
        log.Fatal(err)
    }
}

The core of the system is the built-in Go http package. Without unnecessary frameworks and dependencies. This makes it easier to scale and more convenient to debug. Plus less code means fewer bugs.

The CallbackRequest structure is intentionally simple: just a name and phone number. We will always have time to expand, but throwing out the excess later is a headache.

ExolveConfig keeps API settings in one place. Loading from environment variables – classic 12 factor app. Hardcoding credits into code in 2024 is bad manners, and DevOps won’t understand us.

Security and Validation

Let's add validation and anti-spam functions:

func validatePhone(phone string) bool {
    // Очищаем номер от всего, кроме цифр
    re := regexp.MustCompile(`\D`)
    cleanPhone := re.ReplaceAllString(phone, "")
    
    // Проверяем длину и начало номера
    if len(cleanPhone) != 11 {
        return false
    }
    
    if !strings.HasPrefix(cleanPhone, "7") {
        return false
    }
    
    return true
}

// Простая проверка на спам через in-memory-хранилище
// В реальном проекте лучше использовать Redis
var (
    requestLock    sync.RWMutex
    requestCounter = make(map[string]*rateLimiter)
)

type rateLimiter struct {
    count     int
    firstCall time.Time
}

func isSpamRequest(phone string) bool {
    requestLock.Lock()
    defer requestLock.Unlock()

    now := time.Now()
    limiter, exists := requestCounter[phone]
    
    if !exists {
        requestCounter[phone] = &rateLimiter{
            count:     1,
            firstCall: now,
        }
        return false
    }

    // Сбрасываем счетчик каждый час
    if now.Sub(limiter.firstCall) > time.Hour {
        limiter.count = 1
        limiter.firstCall = now
        return false
    }

    limiter.count++
    // Ограничиваем до 3 запросов в час
    return limiter.count > 3
}

ValidatePhone checks the number based on length and first digit. No tricky regulars – they only complicate support. In addition, validating the number on the backend is the last line of defense; the front should do the main work.

Spam protection via in-memory storage is not ideal, but it will do for a start. Three requests per hour from one number is an adequate limit. We don’t use Redis right away – we start with something simple and complicate it as necessary.

Improving our service

But first, let's see how to process calls through Voice API from Exolve. This will allow us not only to send SMS, but also to automatically make calls.

type VoiceConfig struct {
    ServiceID string // ID нашего голосового сервиса
    Source    string // Номер, с которого будем звонить
}

func makeCallback(phoneNumber string) error {
    callbackBody := struct {
        Source      string `json:"source"`
        Destination string `json:"destination"`
        ServiceID   string `json:"service_id"`
    }{
        Source:      config.Voice.Source,
        Destination: phoneNumber,
        ServiceID:   config.Voice.ServiceID,
    }

    jsonData, err := json.Marshal(callbackBody)
    if err != nil {
        return fmt.Errorf("ошибка при формировании запроса: %v", err)
    }

    req, err := http.NewRequest("POST", "https://api.exolve.ru/call/v1/MakeCallback", bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("ошибка при создании запроса: %v", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", config.ApiKey))

    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("ошибка при выполнении запроса: %v", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return fmt.Errorf("неожиданный статус ответа: %d", resp.StatusCode)
    }

    return nil
}

MakeCallback is our connection with Voice API from Exolve. The structure of the request is as transparent as possible: where we are calling from, where we are calling, what service we are using. No amateur performances – only what is really needed.

Logging and monitoring

An important part of any service is tracking its performance. Let's add structured logging:

type LogEntry struct {
    Time      time.Time `json:"time"`
    Level     string    `json:"level"`
    Phone     string    `json:"phone"`
    Name      string    `json:"name"`
    Status    string    `json:"status"`
    Error     string    `json:"error,omitempty"`
    UserAgent string    `json:"user_agent"`
    IP        string    `json:"ip"`
}

func logRequest(r *http.Request, phone, name, status string, err error) {
    entry := LogEntry{
        Time:      time.Now(),
        Level:     "info",
        Phone:     phone,
        Name:      name,
        Status:    status,
        UserAgent: r.UserAgent(),
        IP:        r.RemoteAddr,
    }

    if err != nil {
        entry.Level = "error"
        entry.Error = err.Error()
    }

    jsonEntry, _ := json.Marshal(entry)
    log.Println(string(jsonEntry))
}

The LogEntry structure is our Swiss Army knife for debugging. Time, level, phone number, name, status – everything that will help you understand what went wrong. UserAgent and IP are a bonus for particularly inquisitive DevOps.

Adding metrics

Prometheus has become the de facto standard for monitoring. Let's add basic metrics:

var (
    requestsTotal = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "callback_requests_total",
            Help: "Общее количество запросов на обратный звонок",
        },
        []string{"status"},
    )

    requestDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "callback_request_duration_seconds",
            Help:    "Время обработки запроса",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
        },
        []string{"status"},
    )
)

// Оборачиваем наш обработчик для сбора метрик
func metricsMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Создаем свой ResponseWriter для отслеживания статуса
        srw := &statusResponseWriter{ResponseWriter: w}
        
        next.ServeHTTP(srw, r)
        
        duration := time.Since(start).Seconds()
        status := fmt.Sprintf("%d", srw.status)
        
        requestsTotal.WithLabelValues(status).Inc()
        requestDuration.WithLabelValues(status).Observe(duration)
    }
}

type statusResponseWriter struct {
    http.ResponseWriter
    status int
}

func (w *statusResponseWriter) WriteHeader(status int) {
    w.status = status
    w.ResponseWriter.WriteHeader(status)
}

RequestsTotal and requestDuration are two main elements of our monitoring. The first one counts requests, the second one measures time. It’s better not to enter unnecessary metrics: these two are already enough to monitor the health of the service.

Caching and optimization

Let's add Redis for more reliable spam control and caching:

type Cache struct {
    client *redis.Client
}

func NewCache(addr string) (*Cache, error) {
    client := redis.NewClient(&redis.Options{
        Addr: addr,
    })

    // Проверяем подключение
    if err := client.Ping().Err(); err != nil {
        return nil, fmt.Errorf("ошибка подключения к Redis: %v", err)
    }

    return &Cache{client: client}, nil
}

func (c *Cache) CheckSpam(phone string) (bool, error) {
    key := fmt.Sprintf("spam:%s", phone)
    
    // Получаем количество запросов
    count, err := c.client.Get(key).Int()
    if err == redis.Nil {
        // Ключа нет, создаем новый
        err = c.client.Set(key, 1, time.Hour).Err()
        return false, err
    }
    if err != nil {
        return false, err
    }

    // Увеличиваем счетчик
    count++
    err = c.client.Set(key, count, time.Hour).Err()
    if err != nil {
        return false, err
    }

    return count > 3, nil
}

The cache structure wraps the Redis client. Spam checking is now more reliable: counters last for an hour and are not afraid of server restarts. And for heavy loads, Redis is just right: fast, reliable, time-tested.

Deployment and configuration

For containerization we use Docker:

FROM golang:1.21-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM alpine:latest

RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/main .
COPY config.yaml .

EXPOSE 8080
CMD ["./main"]

We deploy the Dockerfile in two stages: first we build it, then we package it. Minimum layers – minimum problems. We use light and fast Alpine Linux as a base image.

And docker-compose for local development:

version: '3'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - REDIS_URL=redis:6379
      - EXOLVE_API_KEY=${EXOLVE_API_KEY}
    depends_on:
      - redis

  redis:
    image: redis:alpine
    ports:
      - "6379:6379"

  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=secret
    depends_on:
      - prometheus

Docker-compose is well suited for our task. It combines Redis, Prometheus, Grafana and avoids problems with setting up the environment.

What we got in the end

We have created a simple but reliable call back service with anti-spam protection, monitoring and logging. Thanks to integration with Exsolve API Our solution can not only send SMS, but also automatically make calls. However, it is still worth remembering that this is just an educational prototype and a demonstration of how Exolve can be used in your work. Based on it, you will have to decide how you will make a solution that is suitable specifically for your tasks and needs.

If you have any questions about integration with Exolve API or scaling the service, write in the comments – I will definitely answer.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *