Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
Assisted-by: GLM 5.3 Flash
110 lines
3.2 KiB
Go
110 lines
3.2 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
//go:build linux || freebsd
|
|
|
|
// Package telegram delivers form submission summaries to a Telegram chat
|
|
// through the Bot API. One-way by design: nuntius posts a message and
|
|
// never reads anything back, so there are no conversations, commands or
|
|
// callbacks here, and no bot platform either.
|
|
package telegram
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
|
|
)
|
|
|
|
// Notifier posts submission summaries to one chat through one bot.
|
|
type Notifier struct {
|
|
botToken string
|
|
chatID string
|
|
timeout time.Duration
|
|
// apiURL is the Bot API origin; tests point it at a fake server.
|
|
apiURL string
|
|
}
|
|
|
|
// New returns a Notifier posting as the bot into the chat.
|
|
func New(botToken, chatID string, timeout time.Duration) *Notifier {
|
|
return &Notifier{
|
|
botToken: botToken,
|
|
chatID: chatID,
|
|
timeout: timeout,
|
|
apiURL: "https://api.telegram.org",
|
|
}
|
|
}
|
|
|
|
// Notify posts one submission summary to the chat. Plain text on purpose:
|
|
// a parse mode would turn submitted content into markup that has to be
|
|
// escaped, and plain text cannot be injected.
|
|
func (n *Notifier) Notify(formName string, req contactform.Request) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"chat_id": n.chatID,
|
|
"text": summary(formName, req),
|
|
"disable_web_page_preview": true,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("marshal telegram payload: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequest(http.MethodPost,
|
|
n.apiURL+"/bot"+n.botToken+"/sendMessage", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("build telegram request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: n.timeout}
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return fmt.Errorf("telegram call: %s", redact(err.Error(), n.botToken))
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("telegram sendMessage: HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
// The API answers {"ok":false,"description":...} on refusal, so the
|
|
// body decides, not the status code alone.
|
|
var payload struct {
|
|
OK bool `json:"ok"`
|
|
Description string `json:"description"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil {
|
|
return fmt.Errorf("telegram response: %w", err)
|
|
}
|
|
if !payload.OK {
|
|
return fmt.Errorf("telegram sendMessage: %s", payload.Description)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// summary renders the plain-text message posted to the chat.
|
|
func summary(formName string, req contactform.Request) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "New message on %s\n", formName)
|
|
fmt.Fprintf(&b, "From: %s <%s>\n", req.Name, req.Email)
|
|
if req.Service != "" {
|
|
fmt.Fprintf(&b, "Service interest: %s\n", req.Service)
|
|
}
|
|
b.WriteString("\n")
|
|
b.WriteString(req.Message)
|
|
b.WriteString("\n")
|
|
return b.String()
|
|
}
|
|
|
|
// redact keeps the bot token out of error text: an URL error carries the
|
|
// full request URL, token included, and credentials never reach a log.
|
|
func redact(s, secret string) string {
|
|
if secret == "" {
|
|
return s
|
|
}
|
|
return strings.ReplaceAll(s, secret, "[redacted]")
|
|
}
|