feat: contact form backend for linux and freebsd servers
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
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
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// 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]")
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sourcedock.dev/petrbalvin/nuntius/internal/contactform"
|
||||
)
|
||||
|
||||
var testRequest = contactform.Request{
|
||||
Name: "Jane Doe",
|
||||
Email: "jane@example.com",
|
||||
Service: "architecture",
|
||||
Message: "Hello, I would like to discuss an engagement.",
|
||||
}
|
||||
|
||||
// apiCall carries what the fake API saw; the channel establishes the
|
||||
// happens-before edge the HTTP response alone does not.
|
||||
type apiCall struct {
|
||||
req *http.Request
|
||||
body string
|
||||
}
|
||||
|
||||
// fakeAPI answers with the given payload and records the request; the
|
||||
// returned function waits for the call and yields it.
|
||||
func fakeAPI(t *testing.T, status int, payload string) (*Notifier, func() apiCall) {
|
||||
t.Helper()
|
||||
calls := make(chan apiCall, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
calls <- apiCall{req: r, body: string(b)}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(payload))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
n := New("123:secret", "-100200300", 5*time.Second)
|
||||
n.apiURL = srv.URL
|
||||
return n, func() apiCall {
|
||||
select {
|
||||
case c := <-calls:
|
||||
return c
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the fake API never saw the request")
|
||||
return apiCall{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyPostsTheSummary(t *testing.T) {
|
||||
n, call := fakeAPI(t, http.StatusOK, `{"ok":true}`)
|
||||
|
||||
if err := n.Notify("contact", testRequest); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
got := call()
|
||||
if p := got.req.URL.Path; p != "/bot123:secret/sendMessage" {
|
||||
t.Errorf("path = %q, want the bot token and sendMessage", p)
|
||||
}
|
||||
var payload struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(got.body), &payload); err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
if payload.ChatID != "-100200300" {
|
||||
t.Errorf("chat_id = %q, want the configured chat", payload.ChatID)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"New message on contact",
|
||||
"From: Jane Doe <jane@example.com>",
|
||||
"Service interest: architecture",
|
||||
"Hello, I would like to discuss an engagement.",
|
||||
} {
|
||||
if !strings.Contains(payload.Text, want) {
|
||||
t.Errorf("summary missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyReportsAPIRefusal(t *testing.T) {
|
||||
// The API refuses with a non-200 status and an ok:false body; both
|
||||
// shapes must surface as an error.
|
||||
n, _ := fakeAPI(t, http.StatusUnauthorized, `{"ok":false,"description":"Unauthorized"}`)
|
||||
if err := n.Notify("contact", testRequest); err == nil {
|
||||
t.Errorf("notify over HTTP 401 succeeded, want an error")
|
||||
}
|
||||
|
||||
n, _ = fakeAPI(t, http.StatusOK, `{"ok":false,"description":"chat not found"}`)
|
||||
if err := n.Notify("contact", testRequest); err == nil {
|
||||
t.Errorf("notify over an ok:false body succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotifyRedactsTheToken pins the credential rule: the token never
|
||||
// reaches an error string, and error text is what the log carries.
|
||||
func TestNotifyRedactsTheToken(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// A connection reset surfaces as an URL error carrying the full
|
||||
// request URL, token included.
|
||||
srv.CloseClientConnections()
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
n := New("123:secret", "-100200300", 5*time.Second)
|
||||
n.apiURL = srv.URL
|
||||
|
||||
err := n.Notify("contact", testRequest)
|
||||
if err == nil {
|
||||
t.Fatalf("notify over a killed connection succeeded, want an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "123:secret") {
|
||||
t.Errorf("error text carries the bot token: %q", err.Error())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user