Some checks failed
Docker Build / build-and-push-image (push) Failing after 18s
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.
204 lines
5.7 KiB
Go
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
|