Files
aura-crm/production/backend/internal/notify/handler.go
T

108 lines
3.6 KiB
Go

// Package notify sends staff-facing Telegram push notifications for order
// and cartridge-batch lifecycle events (new item created, status change).
// Fire-and-forget from the caller's perspective — a failed or unconfigured
// send never blocks the write that triggered it (same convention as
// coreclient's heartbeat and site's lead handler: a Telegram outage
// shouldn't take down order creation).
//
// Bot token/chat ID/API base URL come from internal/settings (owner-editable
// via the Settings page, falling back to TG_BOT_TOKEN/TG_CHAT_ID/
// TG_API_BASE_URL env vars if unset in the DB).
//
// The base URL points at a self-hosted Telegram Bot API server
// (https://github.com/tdlib/telegram-bot-api, see docker-compose.yml's
// telegram-bot-api service) rather than api.telegram.org directly — by
// design, not a stopgap (see the project wiki's "local APIs" decision).
// Self-hosting that server requires TG_API_ID/TG_API_HASH from
// my.telegram.org, a one-time manual registration step nobody has done yet
// as of this writing — until then the bot token stays a placeholder and
// Send simply logs a failed delivery, the same fail-soft path an invalid
// real token or a Telegram outage would hit anyway.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"production/internal/settings"
"github.com/jackc/pgx/v5/pgxpool"
)
const sendTimeout = 10 * time.Second
type Handler struct {
db *pgxpool.Pool
client *http.Client
}
func NewHandler(db *pgxpool.Pool) *Handler {
return &Handler{db: db, client: &http.Client{Timeout: sendTimeout}}
}
// Send delivers text to the configured staff chat in the background. It
// never returns an error — none of Send's callers (order/cartridge
// handlers) should have their own request fail just because a staff
// notification didn't go out. Missing bot token/chat ID is a silent no-op,
// not an error: notifications are a convenience layer on top of the Kanban
// board staff already check, not a delivery guarantee. Settings are read
// inside the goroutine, not before spawning it, so Send() itself never adds
// a DB round trip to the caller's own request.
func (h *Handler) Send(text string) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
defer cancel()
s, err := settings.Fetch(ctx, h.db)
if err != nil {
log.Printf("notify: settings fetch failed: %v", err)
return
}
if s.TGBotToken == "" || s.TGChatID == "" {
return
}
if err := h.send(ctx, text, s.TGBotToken, s.TGChatID, s.TGAPIBaseURL); err != nil {
log.Printf("notify: telegram send failed: %v", err)
}
}()
}
func (h *Handler) send(ctx context.Context, text, botToken, chatID, baseURL string) error {
req, err := buildSendRequest(ctx, text, botToken, chatID, baseURL)
if err != nil {
return err
}
resp, err := h.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("telegram API returned status %d", resp.StatusCode)
}
return nil
}
// buildSendRequest is a free function (no Handler/DB needed) so it's unit
// testable in isolation.
func buildSendRequest(ctx context.Context, text, botToken, chatID, baseURL string) (*http.Request, error) {
payload, err := json.Marshal(map[string]string{"chat_id": chatID, "text": text})
if err != nil {
return nil, err
}
url := baseURL + "/bot" + botToken + "/sendMessage"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}