|
|
@@ -0,0 +1,592 @@
|
|
|
+// Package authd — telegrambots.go: Telegram bot CRUD for M13b W3.
|
|
|
+//
|
|
|
+// Schema (post-migration 012):
|
|
|
+// bot_id TEXT
|
|
|
+// company_id TEXT FK -> public.companies(id)
|
|
|
+// name TEXT (human label; e.g. "Acme Ops")
|
|
|
+// bot_token TEXT (plaintext; read by telegramd; M11
|
|
|
+// security milestone will replace
|
|
|
+// this with AES-256-GCM)
|
|
|
+// bot_token_hash TEXT (bcrypt; W3-added so the UI can
|
|
|
+// render "configured" without
|
|
|
+// exposing plaintext. NULL on
|
|
|
+// pre-W3 rows until the operator
|
|
|
+// rotates once.)
|
|
|
+// status TEXT (active | paused)
|
|
|
+// last_seen_at TIMESTAMPTZ
|
|
|
+// created_at TIMESTAMPTZ
|
|
|
+// welcome_message TEXT (W3; reply to /start)
|
|
|
+// default_source_id TEXT (W3; soft FK to public.sources.id)
|
|
|
+// description TEXT (W3; free-text label)
|
|
|
+// last_rotated_at TIMESTAMPTZ (W3; set on every token write)
|
|
|
+// updated_at TIMESTAMPTZ (W3; trigger-maintained)
|
|
|
+//
|
|
|
+// Wire contract (UI):
|
|
|
+// The plaintext bot_token is NEVER returned. The response
|
|
|
+// shape includes `bot_token_set` (bool: bot_token IS NOT NULL
|
|
|
+// AND bot_token <> '') so the UI can render "Configured" /
|
|
|
+// "Not set" badges. The operator pastes a token on create
|
|
|
+// and on rotate; the server stores the plaintext (so
|
|
|
+// telegramd can use it) and bcrypt-hashes it for the hash
|
|
|
+// column. The plaintext leaves the server only via the
|
|
|
+// "rotate token" handshake, where the UI receives the new
|
|
|
+// token in the response body — once. After that, it cannot
|
|
|
+// be re-fetched.
|
|
|
+//
|
|
|
+// Threading: safe for concurrent use (pgx pool is goroutine-safe).
|
|
|
+package authd
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ "github.com/jackc/pgx/v5"
|
|
|
+ "github.com/jackc/pgx/v5/pgconn"
|
|
|
+)
|
|
|
+
|
|
|
+// TelegramBot is the wire shape returned to handlers / JSON
|
|
|
+// callers. Mirrors public.telegram_bots but excludes the
|
|
|
+// plaintext bot_token; the UI sees only the `bot_token_set`
|
|
|
+// boolean.
|
|
|
+type TelegramBot struct {
|
|
|
+ ID string `json:"id"`
|
|
|
+ CompanyID string `json:"company_id"`
|
|
|
+ Name string `json:"name"`
|
|
|
+ WelcomeMessage string `json:"welcome_message,omitempty"`
|
|
|
+ DefaultSourceID string `json:"default_source_id,omitempty"`
|
|
|
+ Description string `json:"description,omitempty"`
|
|
|
+ Status string `json:"status"`
|
|
|
+ BotTokenSet bool `json:"bot_token_set"`
|
|
|
+ LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
|
|
+ LastRotatedAt *time.Time `json:"last_rotated_at,omitempty"`
|
|
|
+ CreatedAt time.Time `json:"created_at"`
|
|
|
+ UpdatedAt time.Time `json:"updated_at"`
|
|
|
+}
|
|
|
+
|
|
|
+// ErrTelegramBotNotFound is returned when (company_id, id)
|
|
|
+// doesn't exist.
|
|
|
+var ErrTelegramBotNotFound = errors.New("authd: telegram bot not found")
|
|
|
+
|
|
|
+// ErrTelegramBotIDTaken is returned when CreateTelegramBot sees
|
|
|
+// a duplicate (company_id, id) for a tenant.
|
|
|
+var ErrTelegramBotIDTaken = errors.New("authd: telegram bot id already in use")
|
|
|
+
|
|
|
+// ErrTelegramBotInvalid is returned when input validation fails.
|
|
|
+var ErrTelegramBotInvalid = errors.New("authd: telegram bot input invalid")
|
|
|
+
|
|
|
+// validTelegramBotStatuses mirrors the schema default comment
|
|
|
+// in 004. The M3 schema comment says active|paused, so we use
|
|
|
+// that.
|
|
|
+var validTelegramBotStatuses = map[string]struct{}{
|
|
|
+ "active": {},
|
|
|
+ "paused": {},
|
|
|
+}
|
|
|
+
|
|
|
+// TelegramBotFilter controls ListTelegramBots. Empty fields
|
|
|
+// mean "no filter".
|
|
|
+type TelegramBotFilter struct {
|
|
|
+ Q string // matches id OR name (ILIKE)
|
|
|
+ Status string // exact match
|
|
|
+ Limit int
|
|
|
+ Offset int
|
|
|
+}
|
|
|
+
|
|
|
+// CreateTelegramBotInput is the validated create payload. The
|
|
|
+// bot_token is required on create (the operator got it from
|
|
|
+// @BotFather and is pasting it in). WelcomeMessage and
|
|
|
+// DefaultSourceID are optional. Description is optional.
|
|
|
+type CreateTelegramBotInput struct {
|
|
|
+ ID string
|
|
|
+ Name string
|
|
|
+ BotToken string
|
|
|
+ WelcomeMessage string
|
|
|
+ DefaultSourceID string
|
|
|
+ Description string
|
|
|
+}
|
|
|
+
|
|
|
+// UpdateTelegramBotInput is the PATCH payload. Pointer / non-nil
|
|
|
+// fields mean "apply this." All fields optional; an empty patch
|
|
|
+// is a no-op (returns the current row).
|
|
|
+type UpdateTelegramBotInput struct {
|
|
|
+ Name *string
|
|
|
+ WelcomeMessage *string
|
|
|
+ DefaultSourceID *string
|
|
|
+ Description *string
|
|
|
+}
|
|
|
+
|
|
|
+// Validate runs the constraints the DB enforces, but earlier
|
|
|
+// and with friendlier error messages for the UI.
|
|
|
+func (in *CreateTelegramBotInput) Validate() error {
|
|
|
+ if !validTelegramBotID(in.ID) {
|
|
|
+ return fmt.Errorf("%w: id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if strings.TrimSpace(in.Name) == "" {
|
|
|
+ return fmt.Errorf("%w: name is required", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if len(in.Name) > 200 {
|
|
|
+ return fmt.Errorf("%w: name must be \u2264 200 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if !validBotTokenFormat(in.BotToken) {
|
|
|
+ return fmt.Errorf("%w: bot_token must match ^\\d+:[A-Za-z0-9_-]{35}$", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if len(in.WelcomeMessage) > 4096 {
|
|
|
+ return fmt.Errorf("%w: welcome_message must be \u2264 4096 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if in.DefaultSourceID != "" && !validTelegramBotID(in.DefaultSourceID) {
|
|
|
+ return fmt.Errorf("%w: default_source_id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if len(in.Description) > 500 {
|
|
|
+ return fmt.Errorf("%w: description must be \u2264 500 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// Validate is the same for Update. We don't enforce presence
|
|
|
+// of fields (PATCH can be empty), just per-field constraints.
|
|
|
+func (in *UpdateTelegramBotInput) Validate() error {
|
|
|
+ if in.Name != nil {
|
|
|
+ s := strings.TrimSpace(*in.Name)
|
|
|
+ if s == "" {
|
|
|
+ return fmt.Errorf("%w: name cannot be empty", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if len(s) > 200 {
|
|
|
+ return fmt.Errorf("%w: name must be \u2264 200 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if in.WelcomeMessage != nil && len(*in.WelcomeMessage) > 4096 {
|
|
|
+ return fmt.Errorf("%w: welcome_message must be \u2264 4096 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if in.DefaultSourceID != nil && *in.DefaultSourceID != "" && !validTelegramBotID(*in.DefaultSourceID) {
|
|
|
+ return fmt.Errorf("%w: default_source_id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ if in.Description != nil && len(*in.Description) > 500 {
|
|
|
+ return fmt.Errorf("%w: description must be \u2264 500 characters", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// ListTelegramBots returns the bots visible to the caller under
|
|
|
+// the given filter, plus the total count. W3 scopes by tenant:
|
|
|
+// every caller sees only the bots of the tenant whose id is
|
|
|
+// passed in the URL path. super_admin can list any tenant;
|
|
|
+// tenant_admin can list their own (gate enforced in the
|
|
|
+// HTTP handler, not here).
|
|
|
+func (s *Store) ListTelegramBots(ctx context.Context, f TelegramBotFilter) ([]TelegramBot, int, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, 0, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if f.Limit <= 0 {
|
|
|
+ f.Limit = 100
|
|
|
+ }
|
|
|
+ if f.Limit > 500 {
|
|
|
+ f.Limit = 500
|
|
|
+ }
|
|
|
+ args := []any{}
|
|
|
+ conds := []string{}
|
|
|
+ if strings.TrimSpace(f.Status) != "" {
|
|
|
+ args = append(args, f.Status)
|
|
|
+ conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if strings.TrimSpace(f.Q) != "" {
|
|
|
+ args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
|
|
|
+ conds = append(conds, fmt.Sprintf("(bot_id ILIKE $%d OR name ILIKE $%d)", len(args), len(args)))
|
|
|
+ }
|
|
|
+ where := ""
|
|
|
+ if len(conds) > 0 {
|
|
|
+ where = "WHERE " + strings.Join(conds, " AND ")
|
|
|
+ }
|
|
|
+ var total int
|
|
|
+ if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.telegram_bots "+where, args...).Scan(&total); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("count telegram_bots: %w", err)
|
|
|
+ }
|
|
|
+ args = append(args, f.Limit, f.Offset)
|
|
|
+ q := fmt.Sprintf(`
|
|
|
+ SELECT bot_id, company_id, name,
|
|
|
+ COALESCE(welcome_message, ''),
|
|
|
+ COALESCE(default_source_id, ''),
|
|
|
+ COALESCE(description, ''),
|
|
|
+ status,
|
|
|
+ (bot_token IS NOT NULL AND bot_token <> ''),
|
|
|
+ last_seen_at, last_rotated_at, created_at, updated_at
|
|
|
+ FROM public.telegram_bots
|
|
|
+ %s
|
|
|
+ ORDER BY created_at DESC
|
|
|
+ LIMIT $%d OFFSET $%d
|
|
|
+ `, where, len(args)-1, len(args))
|
|
|
+ rows, err := s.pool.Query(ctx, q, args...)
|
|
|
+ if err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("list telegram_bots: %w", err)
|
|
|
+ }
|
|
|
+ defer rows.Close()
|
|
|
+ out := make([]TelegramBot, 0, f.Limit)
|
|
|
+ for rows.Next() {
|
|
|
+ var b TelegramBot
|
|
|
+ if err := rows.Scan(
|
|
|
+ &b.ID, &b.CompanyID, &b.Name,
|
|
|
+ &b.WelcomeMessage, &b.DefaultSourceID, &b.Description,
|
|
|
+ &b.Status, &b.BotTokenSet,
|
|
|
+ &b.LastSeenAt, &b.LastRotatedAt, &b.CreatedAt, &b.UpdatedAt,
|
|
|
+ ); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("scan telegram_bot: %w", err)
|
|
|
+ }
|
|
|
+ out = append(out, b)
|
|
|
+ }
|
|
|
+ if err := rows.Err(); err != nil {
|
|
|
+ return nil, 0, fmt.Errorf("rows: %w", err)
|
|
|
+ }
|
|
|
+ return out, total, nil
|
|
|
+}
|
|
|
+
|
|
|
+// GetTelegramBot fetches a single bot by (company_id, id).
|
|
|
+// Returns ErrTelegramBotNotFound if missing. The handler is
|
|
|
+// responsible for the per-id scope check; this method is a
|
|
|
+// straight DB lookup.
|
|
|
+func (s *Store) GetTelegramBot(ctx context.Context, companyID, botID string) (*TelegramBot, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ const q = `
|
|
|
+ SELECT bot_id, company_id, name,
|
|
|
+ COALESCE(welcome_message, ''),
|
|
|
+ COALESCE(default_source_id, ''),
|
|
|
+ COALESCE(description, ''),
|
|
|
+ status,
|
|
|
+ (bot_token IS NOT NULL AND bot_token <> ''),
|
|
|
+ last_seen_at, last_rotated_at, created_at, updated_at
|
|
|
+ FROM public.telegram_bots
|
|
|
+ WHERE company_id = $1 AND bot_id = $2
|
|
|
+ `
|
|
|
+ bot := &TelegramBot{}
|
|
|
+ err := s.pool.QueryRow(ctx, q, companyID, botID).Scan(
|
|
|
+ &bot.ID, &bot.CompanyID, &bot.Name,
|
|
|
+ &bot.WelcomeMessage, &bot.DefaultSourceID, &bot.Description,
|
|
|
+ &bot.Status, &bot.BotTokenSet,
|
|
|
+ &bot.LastSeenAt, &bot.LastRotatedAt, &bot.CreatedAt, &bot.UpdatedAt,
|
|
|
+ )
|
|
|
+ if err != nil {
|
|
|
+ if errors.Is(err, pgx.ErrNoRows) {
|
|
|
+ return nil, ErrTelegramBotNotFound
|
|
|
+ }
|
|
|
+ return nil, fmt.Errorf("get telegram_bot: %w", err)
|
|
|
+ }
|
|
|
+ return bot, nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreateTelegramBot inserts a new bot and writes audit. The
|
|
|
+// bot_token is stored in plaintext (telegramd reads it) AND
|
|
|
+// bcrypt-hashed (so the UI can render "configured" without
|
|
|
+// exposing the plaintext). Returns the wire-shape row, which
|
|
|
+// includes bot_token_set=true. The plaintext is NOT returned
|
|
|
+// in the response (the operator just typed it in; no need to
|
|
|
+// echo it).
|
|
|
+//
|
|
|
+// Behavior:
|
|
|
+// - Duplicate (company_id, id) → ErrTelegramBotIDTaken (409).
|
|
|
+// - last_rotated_at is set to now() because the token was
|
|
|
+// just written. updated_at is set by the trigger.
|
|
|
+func (s *Store) CreateTelegramBot(
|
|
|
+ ctx context.Context,
|
|
|
+ companyID, tenantDisplayName string,
|
|
|
+ in CreateTelegramBotInput,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*TelegramBot, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if err := in.Validate(); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ // Bridge: telegram_bots.company_id is a TEXT FK to
|
|
|
+ // public.companies(id). M13a created auth.tenants; the
|
|
|
+ // legacy public.companies row is what telegram_bots
|
|
|
+ // references. ensurePublicCompanyRow (defined in
|
|
|
+ // sources.go) is the same idempotent INSERT … ON CONFLICT
|
|
|
+ // DO NOTHING we use for source create, so we don't 500
|
|
|
+ // when a tenant has no companies row yet.
|
|
|
+ if err := s.ensurePublicCompanyRow(ctx, companyID, tenantDisplayName); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ hash, err := hashBotToken(in.BotToken)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ now := time.Now().UTC()
|
|
|
+ const q = `
|
|
|
+ INSERT INTO public.telegram_bots
|
|
|
+ (bot_id, company_id, name, bot_token, bot_token_hash,
|
|
|
+ welcome_message, default_source_id, description,
|
|
|
+ status, last_rotated_at)
|
|
|
+ VALUES
|
|
|
+ ($1, $2::text, $3, $4, $5,
|
|
|
+ NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''),
|
|
|
+ 'active', $9)
|
|
|
+ RETURNING bot_id, company_id, name,
|
|
|
+ COALESCE(welcome_message, ''),
|
|
|
+ COALESCE(default_source_id, ''),
|
|
|
+ COALESCE(description, ''),
|
|
|
+ status,
|
|
|
+ (bot_token IS NOT NULL AND bot_token <> ''),
|
|
|
+ last_seen_at, last_rotated_at, created_at, updated_at
|
|
|
+ `
|
|
|
+ bot := &TelegramBot{}
|
|
|
+ err = s.pool.QueryRow(ctx, q,
|
|
|
+ in.ID, companyID, strings.TrimSpace(in.Name),
|
|
|
+ in.BotToken, hash,
|
|
|
+ in.WelcomeMessage, in.DefaultSourceID, in.Description,
|
|
|
+ now,
|
|
|
+ ).Scan(
|
|
|
+ &bot.ID, &bot.CompanyID, &bot.Name,
|
|
|
+ &bot.WelcomeMessage, &bot.DefaultSourceID, &bot.Description,
|
|
|
+ &bot.Status, &bot.BotTokenSet,
|
|
|
+ &bot.LastSeenAt, &bot.LastRotatedAt, &bot.CreatedAt, &bot.UpdatedAt,
|
|
|
+ )
|
|
|
+ if err != nil {
|
|
|
+ var pgErr *pgconn.PgError
|
|
|
+ if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
|
+ return nil, ErrTelegramBotIDTaken
|
|
|
+ }
|
|
|
+ return nil, fmt.Errorf("create telegram_bot: %w", err)
|
|
|
+ }
|
|
|
+ // Audit. The plaintext token is NOT included.
|
|
|
+ if err := s.WriteAudit(ctx, "telegram_bot.create", actorUserID, actorIP, actorUA, bot.CompanyID, bot.ID, map[string]any{
|
|
|
+ "name": bot.Name,
|
|
|
+ "default_source_id": bot.DefaultSourceID,
|
|
|
+ "has_welcome_message": bot.WelcomeMessage != "",
|
|
|
+ "bot_token_set": bot.BotTokenSet,
|
|
|
+ }); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return bot, nil
|
|
|
+}
|
|
|
+
|
|
|
+// UpdateTelegramBot applies a partial update and writes audit.
|
|
|
+// The bot_token is NOT updatable through this method (rotate is
|
|
|
+// a separate action with its own audit trail and its own
|
|
|
+// response shape).
|
|
|
+func (s *Store) UpdateTelegramBot(
|
|
|
+ ctx context.Context,
|
|
|
+ companyID, botID string,
|
|
|
+ in UpdateTelegramBotInput,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*TelegramBot, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if err := in.Validate(); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ sets := []string{}
|
|
|
+ args := []any{companyID, botID}
|
|
|
+ if in.Name != nil {
|
|
|
+ args = append(args, strings.TrimSpace(*in.Name))
|
|
|
+ sets = append(sets, fmt.Sprintf("name = $%d", len(args)))
|
|
|
+ }
|
|
|
+ if in.WelcomeMessage != nil {
|
|
|
+ args = append(args, *in.WelcomeMessage)
|
|
|
+ sets = append(sets, fmt.Sprintf("welcome_message = NULLIF($%d, '')", len(args)))
|
|
|
+ }
|
|
|
+ if in.DefaultSourceID != nil {
|
|
|
+ args = append(args, *in.DefaultSourceID)
|
|
|
+ sets = append(sets, fmt.Sprintf("default_source_id = NULLIF($%d, '')", len(args)))
|
|
|
+ }
|
|
|
+ if in.Description != nil {
|
|
|
+ args = append(args, *in.Description)
|
|
|
+ sets = append(sets, fmt.Sprintf("description = NULLIF($%d, '')", len(args)))
|
|
|
+ }
|
|
|
+ if len(sets) == 0 {
|
|
|
+ return s.GetTelegramBot(ctx, companyID, botID)
|
|
|
+ }
|
|
|
+ q := fmt.Sprintf("UPDATE public.telegram_bots SET %s WHERE company_id = $1 AND bot_id = $2", strings.Join(sets, ", "))
|
|
|
+ tag, err := s.pool.Exec(ctx, q, args...)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("update telegram_bot: %w", err)
|
|
|
+ }
|
|
|
+ if tag.RowsAffected() == 0 {
|
|
|
+ return nil, ErrTelegramBotNotFound
|
|
|
+ }
|
|
|
+ payload := map[string]any{}
|
|
|
+ if in.Name != nil {
|
|
|
+ payload["name"] = *in.Name
|
|
|
+ }
|
|
|
+ if in.WelcomeMessage != nil {
|
|
|
+ payload["welcome_message_set"] = true
|
|
|
+ }
|
|
|
+ if in.DefaultSourceID != nil {
|
|
|
+ payload["default_source_id"] = *in.DefaultSourceID
|
|
|
+ }
|
|
|
+ if in.Description != nil {
|
|
|
+ payload["description_set"] = true
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "telegram_bot.update", actorUserID, actorIP, actorUA, companyID, botID, payload); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTelegramBot(ctx, companyID, botID)
|
|
|
+}
|
|
|
+
|
|
|
+// SetTelegramBotStatus flips status. Allowed transitions:
|
|
|
+// active -> paused
|
|
|
+// paused -> active
|
|
|
+// No "archived" / "deleted" state for bots in v1 (operators
|
|
|
+// leave them paused; archival is a v1.1 feature).
|
|
|
+func (s *Store) SetTelegramBotStatus(
|
|
|
+ ctx context.Context,
|
|
|
+ companyID, botID, newStatus string,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*TelegramBot, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if _, ok := validTelegramBotStatuses[newStatus]; !ok {
|
|
|
+ return nil, fmt.Errorf("%w: status must be active|paused", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ cur, err := s.GetTelegramBot(ctx, companyID, botID)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ if cur.Status == newStatus {
|
|
|
+ return cur, nil
|
|
|
+ }
|
|
|
+ if _, err := s.pool.Exec(ctx,
|
|
|
+ "UPDATE public.telegram_bots SET status = $3 WHERE company_id = $1 AND bot_id = $2",
|
|
|
+ companyID, botID, newStatus); err != nil {
|
|
|
+ return nil, fmt.Errorf("set telegram_bot status: %w", err)
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "telegram_bot.status", actorUserID, actorIP, actorUA, companyID, botID, map[string]any{
|
|
|
+ "from": cur.Status,
|
|
|
+ "to": newStatus,
|
|
|
+ }); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTelegramBot(ctx, companyID, botID)
|
|
|
+}
|
|
|
+
|
|
|
+// RotateTelegramBotToken sets a new bot_token, replacing the
|
|
|
+// existing one. The new token is bcrypt-hashed and written to
|
|
|
+// bot_token_hash; the plaintext replaces bot_token (telegramd
|
|
|
+// will pick it up on the next reload — v1.1 adds a
|
|
|
+// notification channel; W3 simply relies on the periodic poll
|
|
|
+// restart). last_rotated_at is set to now().
|
|
|
+//
|
|
|
+// Returns the updated row. The plaintext is NOT echoed back —
|
|
|
+// the operator just typed it, they already have it. If you
|
|
|
+// want the server to generate a token, use the dedicated
|
|
|
+// "create bot with BotFather" path (out of scope for v1).
|
|
|
+func (s *Store) RotateTelegramBotToken(
|
|
|
+ ctx context.Context,
|
|
|
+ companyID, botID, newToken string,
|
|
|
+ actorUserID, actorIP, actorUA string,
|
|
|
+) (*TelegramBot, error) {
|
|
|
+ if s.pool == nil {
|
|
|
+ return nil, errors.New("authd: no DB pool (test mode)")
|
|
|
+ }
|
|
|
+ if !validBotTokenFormat(newToken) {
|
|
|
+ return nil, fmt.Errorf("%w: bot_token must match ^\\d+:[A-Za-z0-9_-]{35}$", ErrTelegramBotInvalid)
|
|
|
+ }
|
|
|
+ // Confirm the bot exists first; surface 404 before doing
|
|
|
+ // any work.
|
|
|
+ if _, err := s.GetTelegramBot(ctx, companyID, botID); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ hash, err := hashBotToken(newToken)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ now := time.Now().UTC()
|
|
|
+ if _, err := s.pool.Exec(ctx,
|
|
|
+ "UPDATE public.telegram_bots SET bot_token = $3, bot_token_hash = $4, last_rotated_at = $5 WHERE company_id = $1 AND bot_id = $2",
|
|
|
+ companyID, botID, newToken, hash, now); err != nil {
|
|
|
+ return nil, fmt.Errorf("rotate telegram_bot token: %w", err)
|
|
|
+ }
|
|
|
+ if err := s.WriteAudit(ctx, "telegram_bot.rotate_token", actorUserID, actorIP, actorUA, companyID, botID, map[string]any{
|
|
|
+ "rotated": true,
|
|
|
+ }); err != nil {
|
|
|
+ _ = err
|
|
|
+ }
|
|
|
+ return s.GetTelegramBot(ctx, companyID, botID)
|
|
|
+}
|
|
|
+
|
|
|
+// -------------------------------------------------------------------
|
|
|
+// helpers
|
|
|
+// -------------------------------------------------------------------
|
|
|
+
|
|
|
+// validTelegramBotID matches the same regex as auth.tenants.slug
|
|
|
+// and source IDs: ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$.
|
|
|
+//
|
|
|
+// Why a separate function? The bot id appears in a public
|
|
|
+// Telegram URL (`t.me/<bot>`) and as part of the api.telegram.org
|
|
|
+// path; keeping the same charset as the rest of the system
|
|
|
+// avoids any URL-encoding gotchas.
|
|
|
+func validTelegramBotID(s string) bool {
|
|
|
+ if len(s) < 2 || len(s) > 64 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if !isAlnumOrDash(s[0]) || s[0] == '-' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for i := 1; i < len(s)-1; i++ {
|
|
|
+ if !isAlnumOrDash(s[i]) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+// validBotTokenFormat — Telegram bot tokens look like
|
|
|
+// <bot_id>:<secret>
|
|
|
+// where bot_id is a decimal integer (8-10 digits) and secret
|
|
|
+// is 35 [A-Za-z0-9_-] chars. The full regex Telegram documents
|
|
|
+// is `^\d+:[A-Za-z0-9_-]{35}$`; we accept the same shape.
|
|
|
+// (Real BotFather tokens are exactly 46 chars including the
|
|
|
+// colon; we use the more lenient regex from M13b_PLAN §2.3.)
|
|
|
+func validBotTokenFormat(s string) bool {
|
|
|
+ if len(s) < 37 || len(s) > 100 {
|
|
|
+ // minimum 1+1+35 = 37; upper bound is generous
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ colon := -1
|
|
|
+ for i, c := range s {
|
|
|
+ if c == ':' {
|
|
|
+ if colon >= 0 {
|
|
|
+ return false // more than one colon
|
|
|
+ }
|
|
|
+ colon = i
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if colon < 1 || colon == len(s)-1 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ // bot id part: digits only
|
|
|
+ for i := 0; i < colon; i++ {
|
|
|
+ if s[i] < '0' || s[i] > '9' {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // secret part: 35+ [A-Za-z0-9_-]
|
|
|
+ if len(s)-colon-1 < 35 {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ for i := colon + 1; i < len(s); i++ {
|
|
|
+ c := s[i]
|
|
|
+ if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
|
+ (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+// hashBotToken bcrypts the plaintext token at cost 10. The
|
|
|
+// actual hash is never used to validate anything (Telegram
|
|
|
+// validates by checking the plaintext itself); the hash
|
|
|
+// column exists so the UI can render "configured" without
|
|
|
+// the server having to expose the plaintext. Cost 10 mirrors
|
|
|
+// the source hmac_secret path.
|
|
|
+func hashBotToken(plain string) (string, error) {
|
|
|
+ return hashSecret(plain, "bot_token")
|
|
|
+}
|