Files
telegram-gateway/telegram.go
Gabriel Radureau 07115e3162
Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
Phase 1.5 — auth layer (Redis sessions, allowlist, requireAuth)
Adds an authentication layer in front of the bot handlers :

- Auth handler on the principal bot (@arcodange_factory_bot, slug
  factory) parses /start, /auth <code>, /whoami, /logout. On a
  successful /auth, the message containing the code is best-effort
  deleted from the user's chat (replay defense).
- Redis-backed sessions (key tg-gw:auth:<from.id>, TTL 24h, configurable
  via AUTH_SESSION_TTL). Constant-time secret compare via crypto/subtle.
- ALLOWED_USERS env (CSV of Telegram user IDs) — silent-drops anyone
  not in the list before the auth gate runs.
- New per-bot field 'requireAuth' (pointer-bool). Default = true (secure
  by default). Auto-forced to false for handler=auth (chicken-and-egg).
- Server gates: allowlist first, then requireAuth before handler dispatch.
- Fail-at-startup if a bot is configured with handler=auth or
  requireAuth: true while AUTH_SECRET is unset.

Design: factory/docs/adr/20260509-telegram-gateway-auth.md (in factory PR).
User docs: AUTH.md (new), HOWTO_ADD_BOT.md (Cas 2 updated for default
true and gated flow).

New deps: github.com/redis/go-redis/v9.

Refs ~/.claude/plans/pour-les-notifications-on-inherited-seal.md § Phase 1.5.
2026-05-09 13:56:30 +02:00

204 lines
5.7 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
const telegramAPIBase = "https://api.telegram.org"
type TelegramClient struct {
httpClient *http.Client
apiBase string
}
func NewTelegramClient() *TelegramClient {
return &TelegramClient{
httpClient: &http.Client{Timeout: 10 * time.Second},
apiBase: telegramAPIBase,
}
}
type SendMessageParams struct {
ChatID int64 `json:"chat_id"`
Text string `json:"text"`
}
type apiResponse struct {
OK bool `json:"ok"`
Description string `json:"description,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
}
func (c *TelegramClient) SendMessage(ctx context.Context, token string, params SendMessageParams) error {
body, err := json.Marshal(params)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/bot%s/sendMessage", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram api error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
type SetWebhookParams struct {
URL string `json:"url"`
SecretToken string `json:"secret_token,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
AllowedUpdates []string `json:"allowed_updates,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
}
func (c *TelegramClient) SetWebhook(ctx context.Context, token string, params SetWebhookParams) error {
body, err := json.Marshal(params)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/bot%s/setWebhook", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram setWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
func (c *TelegramClient) DeleteWebhook(ctx context.Context, token string) error {
endpoint := fmt.Sprintf("%s/bot%s/deleteWebhook", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram deleteWebhook error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
type WebhookInfo struct {
URL string `json:"url"`
HasCustomCert bool `json:"has_custom_certificate"`
PendingUpdateCount int `json:"pending_update_count"`
LastErrorDate int64 `json:"last_error_date,omitempty"`
LastErrorMessage string `json:"last_error_message,omitempty"`
}
func (c *TelegramClient) GetWebhookInfo(ctx context.Context, token string) (*WebhookInfo, error) {
endpoint := fmt.Sprintf("%s/bot%s/getWebhookInfo", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return nil, fmt.Errorf("decode telegram response: %w", jerr)
}
if !ar.OK {
return nil, fmt.Errorf("telegram getWebhookInfo error: %s", ar.Description)
}
var info WebhookInfo
if jerr := json.Unmarshal(ar.Result, &info); jerr != nil {
return nil, fmt.Errorf("decode webhook info: %w", jerr)
}
return &info, nil
}
type DeleteMessageParams struct {
ChatID int64 `json:"chat_id"`
MessageID int64 `json:"message_id"`
}
// DeleteMessage removes a message from a chat. Used as best-effort replay
// defense after a successful /auth (we delete the message that contained
// the secret). See factory/docs/adr/20260509-telegram-gateway-auth.md.
func (c *TelegramClient) DeleteMessage(ctx context.Context, token string, chatID, messageID int64) error {
body, err := json.Marshal(DeleteMessageParams{ChatID: chatID, MessageID: messageID})
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/bot%s/deleteMessage", c.apiBase, token)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var ar apiResponse
if jerr := json.Unmarshal(respBody, &ar); jerr != nil {
return fmt.Errorf("decode telegram response (status %d): %w", resp.StatusCode, jerr)
}
if !ar.OK {
return fmt.Errorf("telegram deleteMessage error (code=%d): %s", ar.ErrorCode, ar.Description)
}
return nil
}
// botBuildURL is exposed for tests; not used directly.
var _ = url.Parse