// Copyright (c) 2026 Petr BalvĂ­n (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]") }