108 lines
3.6 KiB
Go
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
|
|
}
|