// telegrambots.go — HTTP handlers for the /v1/tenants/{id}/telegram/bots/* // routes (M13b W3). // // Routes (all require super_admin role per the existing // canManageTelegram scope): // // GET /v1/tenants/{id}/telegram/bots — list // POST /v1/tenants/{id}/telegram/bots — create // GET /v1/tenants/{id}/telegram/bots/{bid} — detail // PATCH /v1/tenants/{id}/telegram/bots/{bid} — update // POST /v1/tenants/{id}/telegram/bots/{bid}/status — set status // POST /v1/tenants/{id}/telegram/bots/{bid}/rotate-token — rotate token // // Errors: // 400 — bad input (validation, JSON parse, bad UUID, bad bot id) // 401 — handled by RequireAuth middleware (no body rewrite here) // 403 — caller is not super_admin (RequireRole gate) // 404 — tenant or bot id not found // 409 — duplicate bot id on create // 500 — unexpected DB error // // The plaintext bot_token is NEVER returned in any response. // The wire shape is the authd.TelegramBot struct, which exposes // `bot_token_set: bool` instead. package main import ( "encoding/json" "errors" "log/slog" "net/http" "strconv" "strings" "git3.techno-world.net/lrosales/broad-announce/internal/authd" ) // telegramBotsListResponse is the wire shape for GET // /v1/tenants/{id}/telegram/bots. type telegramBotsListResponse struct { Items []authd.TelegramBot `json:"items"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } // listTelegramBotsHandler wires GET /v1/tenants/{id}/telegram/bots. func listTelegramBotsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } q := strings.TrimSpace(r.URL.Query().Get("q")) statusFilter := strings.TrimSpace(r.URL.Query().Get("status")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) filter := authd.TelegramBotFilter{ CompanyID: tenantID, Q: q, Status: statusFilter, Limit: limit, Offset: offset, } items, total, err := ad.Store().ListTelegramBots(r.Context(), filter) if err != nil { logger.Error("list telegram_bots", "err", err, "actor", claims.UserID, "tenant_id", tenantID) writeErr(w, http.StatusInternalServerError, "internal", "list failed") return } if filter.Limit <= 0 { filter.Limit = 100 } if filter.Limit > 500 { filter.Limit = 500 } writeJSON(w, http.StatusOK, telegramBotsListResponse{ Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset, }) } } // createTelegramBotRequest is the POST body. bot_token is // REQUIRED on create (the operator got it from @BotFather). // All other fields optional. type createTelegramBotRequest struct { ID string `json:"id"` Name string `json:"name"` BotToken string `json:"bot_token"` WelcomeMessage string `json:"welcome_message"` DefaultSourceID string `json:"default_source_id"` Description string `json:"description"` } // createTelegramBotHandler wires POST /v1/tenants/{id}/telegram/bots. func createTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } // W3 gates telegram to super_admin only (see canManageTelegram // in web/src/lib/scope.ts). RequireRole is wired in main.go; // this is belt-and-suspenders. if claims.Role != "super_admin" { writeErr(w, http.StatusForbidden, "forbidden", "super_admin only") return } var req createTelegramBotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } in := authd.CreateTelegramBotInput{ ID: strings.TrimSpace(req.ID), Name: strings.TrimSpace(req.Name), BotToken: strings.TrimSpace(req.BotToken), WelcomeMessage: req.WelcomeMessage, DefaultSourceID: strings.TrimSpace(req.DefaultSourceID), Description: req.Description, } // Look up the auth tenant's display name so the // bridge INSERT into public.companies (needed because // telegram_bots.company_id FKs into public.companies, // not auth.tenants) has a sensible name value. tenant, err := ad.Store().GetTenant(r.Context(), tenantID) if err != nil { if errors.Is(err, authd.ErrTenantNotFound) { writeErr(w, http.StatusNotFound, "not_found", "tenant not found") return } logger.Error("create telegram_bot: lookup tenant", "err", err, "tenant_id", tenantID) writeErr(w, http.StatusInternalServerError, "internal", "lookup failed") return } ip := clientIP(r) ua := r.UserAgent() bot, err := ad.Store().CreateTelegramBot(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTelegramBotIDTaken): writeErr(w, http.StatusConflict, "id_taken", "telegram bot id already in use") case errors.Is(err, authd.ErrTelegramBotInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("create telegram_bot", "err", err, "tenant_id", tenantID, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "create failed") } return } logger.Info("telegram_bot created", "tenant_id", tenantID, "bot_id", bot.ID, "actor", claims.UserID) writeJSON(w, http.StatusCreated, bot) } } // getTelegramBotHandler wires GET /v1/tenants/{id}/telegram/bots/{bid}. func getTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if claims.Role != "super_admin" { writeErr(w, http.StatusForbidden, "forbidden", "super_admin only") return } botID := r.PathValue("bid") if botID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required") return } bot, err := ad.Store().GetTelegramBot(r.Context(), tenantID, botID) if err != nil { if errors.Is(err, authd.ErrTelegramBotNotFound) { writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found") return } logger.Error("get telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID) writeErr(w, http.StatusInternalServerError, "internal", "lookup failed") return } writeJSON(w, http.StatusOK, bot) } } // updateTelegramBotRequest is the PATCH body. bot_token is // NOT updatable here — use POST .../rotate-token. type updateTelegramBotRequest struct { Name *string `json:"name"` WelcomeMessage *string `json:"welcome_message"` DefaultSourceID *string `json:"default_source_id"` Description *string `json:"description"` } // updateTelegramBotHandler wires PATCH /v1/tenants/{id}/telegram/bots/{bid}. func updateTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if claims.Role != "super_admin" { writeErr(w, http.StatusForbidden, "forbidden", "super_admin only") return } botID := r.PathValue("bid") if botID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required") return } var req updateTelegramBotRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } in := authd.UpdateTelegramBotInput{ Name: req.Name, WelcomeMessage: req.WelcomeMessage, DefaultSourceID: req.DefaultSourceID, Description: req.Description, } ip := clientIP(r) ua := r.UserAgent() bot, err := ad.Store().UpdateTelegramBot(r.Context(), tenantID, botID, in, claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTelegramBotNotFound): writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found") case errors.Is(err, authd.ErrTelegramBotInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("update telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "update failed") } return } logger.Info("telegram_bot updated", "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID) writeJSON(w, http.StatusOK, bot) } } // setTelegramBotStatusRequest is the POST /status body. type setTelegramBotStatusRequest struct { Status string `json:"status"` } // setTelegramBotStatusHandler wires POST /v1/tenants/{id}/telegram/bots/{bid}/status. func setTelegramBotStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if claims.Role != "super_admin" { writeErr(w, http.StatusForbidden, "forbidden", "super_admin only") return } botID := r.PathValue("bid") if botID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required") return } var req setTelegramBotStatusRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } ip := clientIP(r) ua := r.UserAgent() bot, err := ad.Store().SetTelegramBotStatus(r.Context(), tenantID, botID, strings.TrimSpace(req.Status), claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTelegramBotNotFound): writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found") case errors.Is(err, authd.ErrTelegramBotInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("set telegram_bot status", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "update failed") } return } logger.Info("telegram_bot status changed", "tenant_id", tenantID, "bot_id", botID, "to", bot.Status, "actor", claims.UserID) writeJSON(w, http.StatusOK, bot) } } // rotateTelegramBotTokenRequest is the POST /rotate-token body. // bot_token is REQUIRED (the operator got a new one from // @BotFather and is pasting it in). type rotateTelegramBotTokenRequest struct { BotToken string `json:"bot_token"` } // rotateTelegramBotTokenHandler wires POST // /v1/tenants/{id}/telegram/bots/{bid}/rotate-token. // // The new bot_token replaces the existing one in the DB and is // bcrypt-hashed for the bot_token_hash column. The response // does NOT include the plaintext (the operator already has it; // the server doesn't echo it back). func rotateTelegramBotTokenHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := authd.ClaimsFromContext(r.Context()) if claims == nil { writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing") return } tenantID := r.PathValue("id") if !isUUID(tenantID) { writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID") return } if claims.Role != "super_admin" { writeErr(w, http.StatusForbidden, "forbidden", "super_admin only") return } botID := r.PathValue("bid") if botID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required") return } var req rotateTelegramBotTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body") return } ip := clientIP(r) ua := r.UserAgent() bot, err := ad.Store().RotateTelegramBotToken(r.Context(), tenantID, botID, strings.TrimSpace(req.BotToken), claims.UserID, ip, ua) if err != nil { switch { case errors.Is(err, authd.ErrTelegramBotNotFound): writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found") case errors.Is(err, authd.ErrTelegramBotInvalid): writeErr(w, http.StatusBadRequest, "invalid", err.Error()) default: logger.Error("rotate telegram_bot token", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID) writeErr(w, http.StatusInternalServerError, "internal", "rotate failed") } return } logger.Info("telegram_bot token rotated", "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID) writeJSON(w, http.StatusOK, bot) } }