first
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// settingsResponse never includes the raw bot token (spec section 32: secrets
|
||||
// must never be exposed to the frontend) -- only whether one is configured.
|
||||
type settingsResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotTokenConfigured bool `json:"bot_token_configured"`
|
||||
ChatID string `json:"chat_id"`
|
||||
NotifyNewOrder bool `json:"notify_new_order"`
|
||||
NotifyStatusChange bool `json:"notify_status_change"`
|
||||
}
|
||||
|
||||
func toResponse(s *Settings) settingsResponse {
|
||||
return settingsResponse{
|
||||
Enabled: s.Enabled,
|
||||
BotTokenConfigured: s.BotToken != "",
|
||||
ChatID: s.ChatID,
|
||||
NotifyNewOrder: s.NotifyNewOrder,
|
||||
NotifyStatusChange: s.NotifyStatusChange,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
settings, err := h.service.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load telegram settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ChatID string `json:"chat_id" binding:"required_if=Enabled true"`
|
||||
NotifyNewOrder bool `json:"notify_new_order"`
|
||||
NotifyStatusChange bool `json:"notify_status_change"`
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
settings, err := h.service.Update(c.Request.Context(), req.Enabled, req.BotToken, req.ChatID, req.NotifyNewOrder, req.NotifyStatusChange)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update telegram settings"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, toResponse(settings))
|
||||
}
|
||||
|
||||
func (h *Handler) Test(c *gin.Context) {
|
||||
if err := h.service.SendTestMessage(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "test notification sent"})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package telegram is the notifications module (spec section 20): the
|
||||
// admin configures a bot token + chat id and toggles which events trigger a
|
||||
// message. It is a pluggable notifier -- other modules (orders) depend on
|
||||
// the small OrderNotifier interface it satisfies, never on Telegram
|
||||
// directly, so a future module (email, WhatsApp, ...) can be swapped in the
|
||||
// same way.
|
||||
package telegram
|
||||
|
||||
import "time"
|
||||
|
||||
type Settings struct {
|
||||
ID int16 `gorm:"primaryKey"`
|
||||
Enabled bool
|
||||
BotToken string
|
||||
ChatID string
|
||||
NotifyNewOrder bool
|
||||
NotifyStatusChange bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Settings) TableName() string { return "telegram_settings" }
|
||||
@@ -0,0 +1,44 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Get(ctx context.Context) (*Settings, error)
|
||||
Update(ctx context.Context, s *Settings) error
|
||||
}
|
||||
|
||||
type gormRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &gormRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *gormRepository) Get(ctx context.Context) (*Settings, error) {
|
||||
var s Settings
|
||||
if err := r.db.WithContext(ctx).First(&s, "id = 1").Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *gormRepository) Update(ctx context.Context, s *Settings) error {
|
||||
s.ID = 1
|
||||
updates := map[string]any{
|
||||
"enabled": s.Enabled,
|
||||
"chat_id": s.ChatID,
|
||||
"notify_new_order": s.NotifyNewOrder,
|
||||
"notify_status_change": s.NotifyStatusChange,
|
||||
}
|
||||
// bot_token is only overwritten when explicitly provided (see service.go):
|
||||
// a blank value in the update request means "keep the existing secret".
|
||||
if s.BotToken != "" {
|
||||
updates["bot_token"] = s.BotToken
|
||||
}
|
||||
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(updates).Error
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package telegram
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFunc) {
|
||||
group := rg.Group("/admin/notifications/telegram", requireAdmin)
|
||||
group.GET("", h.Get)
|
||||
group.PUT("", h.Update)
|
||||
group.POST("/test", h.Test)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sender abstracts the actual Telegram Bot API call so the service layer
|
||||
// can be unit-tested without making real network requests.
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, botToken, chatID, text string) error
|
||||
}
|
||||
|
||||
type httpSender struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPSender() Sender {
|
||||
return &httpSender{client: &http.Client{Timeout: 10 * time.Second}}
|
||||
}
|
||||
|
||||
func (s *httpSender) Send(ctx context.Context, botToken, chatID, text string) error {
|
||||
if botToken == "" || chatID == "" {
|
||||
return fmt.Errorf("telegram bot token and chat id must be configured")
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken)
|
||||
form := url.Values{
|
||||
"chat_id": {chatID},
|
||||
"text": {text},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build telegram request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("call telegram api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return fmt.Errorf("telegram api returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
sender Sender
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewService(repo Repository, sender Sender, logger *slog.Logger) *Service {
|
||||
return &Service{repo: repo, sender: sender, logger: logger}
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context) (*Settings, error) {
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
// Update persists the settings. botToken == "" means "leave the currently
|
||||
// stored token untouched" -- the admin never has to re-type the secret just
|
||||
// to flip a checkbox, and the API never has to echo it back.
|
||||
func (s *Service) Update(ctx context.Context, enabled bool, botToken, chatID string, notifyNewOrder, notifyStatusChange bool) (*Settings, error) {
|
||||
settings := &Settings{
|
||||
Enabled: enabled,
|
||||
BotToken: botToken,
|
||||
ChatID: chatID,
|
||||
NotifyNewOrder: notifyNewOrder,
|
||||
NotifyStatusChange: notifyStatusChange,
|
||||
}
|
||||
if err := s.repo.Update(ctx, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.Get(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) SendTestMessage(ctx context.Context) error {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.BotToken == "" || settings.ChatID == "" {
|
||||
return fmt.Errorf("bot token and chat id must be configured before testing")
|
||||
}
|
||||
return s.sender.Send(ctx, settings.BotToken, settings.ChatID, "Test notification: your Telegram integration is working.")
|
||||
}
|
||||
|
||||
// NotifyNewOrder implements orders.OrderNotifier. It never returns an error
|
||||
// to the caller: a broken/misconfigured Telegram integration must not
|
||||
// prevent a customer's order from being created, so failures are only
|
||||
// logged.
|
||||
func (s *Service) NotifyNewOrder(ctx context.Context, summary string) {
|
||||
s.notify(ctx, func(st *Settings) bool { return st.NotifyNewOrder }, summary)
|
||||
}
|
||||
|
||||
func (s *Service) NotifyOrderStatusChange(ctx context.Context, summary string) {
|
||||
s.notify(ctx, func(st *Settings) bool { return st.NotifyStatusChange }, summary)
|
||||
}
|
||||
|
||||
func (s *Service) notify(ctx context.Context, shouldSend func(*Settings) bool, text string) {
|
||||
settings, err := s.repo.Get(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("telegram: failed to load settings", "error", err)
|
||||
return
|
||||
}
|
||||
if !settings.Enabled || !shouldSend(settings) {
|
||||
return
|
||||
}
|
||||
if err := s.sender.Send(ctx, settings.BotToken, settings.ChatID, text); err != nil {
|
||||
s.logger.Error("telegram: failed to send notification", "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user