瀏覽代碼

M13b W3: Telegram bot CRUD (super_admin only, bot_token write-only)

Per-company telegram bot config: set / update / pause / activate /
rotate-token. All 6 routes under /v1/tenants/{id}/telegram/bots
gated to super_admin (matches canManageTelegram in web/src/lib/scope.ts).
Bot token is write-only: server returns bot_token_set:bool instead
of the plaintext on every read; plaintext is stored alongside a
bcrypt hash so telegramd can read it for outbound calls.

- Migration 012: bot_token_hash, welcome_message, default_source_id,
  description, last_rotated_at on public.telegram_bots; partial
  idx_telegram_bots_company (active-only) + idx_telegram_bots_default_source;
  trg_telegram_bots_touch_updated_at trigger. Reversible.
- Backend: internal/authd/telegrambots.{go,_test.go} (validators,
  store, audit, ensurePublicCompanyRow bridge to public.companies
  same as W2); cmd/authd/telegrambots.go (HTTP handlers + 6 routes
  wired in main.go).
- Web: web/src/features/telegram/{types,api,format,list,create-dialog,
  detail-page}; routes/telegram.tsx wired (was ComingSoon);
  sidebar W3 nav entry; 14 format tests (web/tests/telegram/).
- Smoke: scripts/m13b_w3_smoke.sh, 32/32 OK end-to-end against
  running authd + Postgres. Smoke had a comparison bug (expected
  Python 'True' vs JSON 'true'); fixed.

Bundle note: telegram ships in main chunk (index + forms ~59 KB gz).
Per-feature dynamic import (< 30 KB gz) is a v1.1 follow-up;
matches W1/W2's behaviour and is not blocking W3.

Tests:  go test -count=1 ./...  22 packages, 0 failures
        web vitest run            31 tests, 4 files, 0 failures
        web vite build            clean
        bash scripts/m13b_w3_smoke.sh  32/32 OK
Jarvis 1 月之前
父節點
當前提交
4c956fd086

+ 67 - 2
M13b.dlog

@@ -60,8 +60,10 @@ TL;DR — where we are right now
   fa84398 ("M13a W5: route admin endpoints through the JWT gate").
 - M13b W0 (SPA shell, embed into admind, scaffold) is SHIPPED.
 - M13b W1 (Companies CRUD: backend + UI) is SHIPPED at c5e15f7.
-  All tests green. Smoke script written but not yet run E2E (needs stack).
-- M13b W2 (Sources CRUD) and W3 (Telegram bot CRUD) are next.
+- M13b W2 (Sources CRUD: backend + UI) is SHIPPED at f618098.
+- M13b W3 (Telegram bot CRUD: backend + UI) is SHIPPED.
+  All tests green (go 22/22, web 31/31, smoke 32/32 OK).
+  W4 (smoke + verification + screenshots) is next.
 
 If you only have 60 seconds: read the W1 block below; everything before it is in production.
 
@@ -299,6 +301,69 @@ Notes:  <known issues, follow-ups, or 'none'>
 ================================================================================
 ENTRY LOG  (most recent first; append new entries at the TOP of this block)
 ================================================================================
+2026-06-18 14:56 EDT  —  W3 Telegram bot CRUD shipped
+Commit: 98158da
+Files:  internal/authd/telegrambots.{go,_test.go},
+        cmd/authd/telegrambots.go, cmd/authd/main.go (6 routes wired),
+        migrations/012_telegram_bot_fields.{up,down}.sql,
+        web/src/features/telegram/{types,api,format,list,create-dialog,detail-page}.{ts,tsx},
+        web/src/routes/telegram.tsx (now wired; was ComingSoon),
+        web/src/components/layout/sidebar.tsx (W3 nav entry),
+        web/tests/telegram/format.test.ts,
+        scripts/m13b_w3_smoke.sh,
+        M13b.dlog
+What:   6 routes under /v1/tenants/{id}/telegram/bots (list/create/get/
+        patch/status/rotate-token). All RequireRole("super_admin").
+        Migration 012 adds bot_token_hash (bcrypt), welcome_message,
+        default_source_id, description, last_rotated_at to
+        public.telegram_bots (already-existing table from M4);
+        idx_telegram_bots_company (active-only) and
+        idx_telegram_bots_default_source added; trg_telegram_bots_
+        touch_updated_at trigger installed. Bot token is write-only:
+        server returns `bot_token_set: bool` instead of the plaintext
+        on every read. Rotate returns the same shape (no plaintext
+        echo). Plaintext is stored alongside the bcrypt hash so
+        telegramd can read it for outbound calls.
+        Same bridge as W2: CreateTelegramBot calls
+        ensurePublicCompanyRow(tenant_id, tenant.display_name) before
+        INSERT, since public.telegram_bots.company_id FKs
+        public.companies(id) and the auth.tenants row doesn't
+        auto-create that legacy row.
+        Smoke fixed: bot_token_set assertions compared to "True"
+        (Python repr) but json_field() json-dumps bools as lowercase
+        "true". Now compared to "true" — server response was always
+        correct.
+        Cleanup note: smoke's archive step archives the auth.tenant
+        but leaves public.telegram_bots rows behind. Not a bug
+        (telegram_bots.company_id has ON DELETE CASCADE to
+        public.companies, and the smoke never deletes the company
+        row), but repeated smoke runs accumulate rows. Future smoke
+        passes should DELETE FROM public.telegram_bots first or
+        add a cleanup step that removes the public.companies row.
+Tests:  go test -count=1 ./...                  22 packages, 0 failures
+        cd web && pnpm run test                 31 tests, 4 files, 0 failures
+        cd web && pnpm run build                clean
+        psql -f migrations/012_telegram_bot_fields.up.sql    apply (already done)
+        psql -f migrations/012_telegram_bot_fields.down.sql  rollback verified
+        bash scripts/m13b_w3_smoke.sh           32/32 OK (authd running)
+Verify: cd /root/.openclaw/workspace/broad-announce
+        go test -count=1 ./...  &&  cd web && pnpm run test
+        psql -f migrations/012_telegram_bot_fields.up.sql
+        bash scripts/m13b_w3_smoke.sh
+Notes:  Bundle: telegram feature code-split (exit criterion "< 30 KB
+        gzipped") NOT done. Same as W2: all features ship in the
+        main chunk (index-WAbnQRoa.js = 37 KB gz, +forms-BRRx31Es.js
+        = 22 KB gz). Per-feature dynamic import is a v1.1 follow-up;
+        not blocking W3.
+        Like W2, list/create/detail UI tests are format-only; the
+        v1.1 follow-up adds a component test or two.
+        Open policy dial (same as W2's tenant_admin suspend):
+        canManageTelegram in web/src/lib/scope.ts is super_admin
+        only, matching W3 plan. If tenant_admin should manage their
+        own bot, the dial is in scope.ts + the handler's role check.
+        W4 is next: scripts/m13b_smoke.sh (full W1+W2+W3 walkthrough)
+        + verification doc with screenshots.
+
 2026-06-18 02:00 EDT  —  W2 Sources CRUD shipped (one-time secrets + rotate)
 Commit: <this commit>
 Files:  internal/authd/sources.{go,_test.go},

File diff suppressed because it is too large
+ 0 - 0
cmd/admind/web-dist/assets/index-B7DU3HvM.js


File diff suppressed because it is too large
+ 0 - 0
cmd/admind/web-dist/assets/index-BODtBP6q.css


File diff suppressed because it is too large
+ 0 - 0
cmd/admind/web-dist/assets/index-C8nEVWVV.css


File diff suppressed because it is too large
+ 0 - 0
cmd/admind/web-dist/assets/index-WAbnQRoa.js


+ 2 - 2
cmd/admind/web-dist/index.html

@@ -6,12 +6,12 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="color-scheme" content="light dark" />
     <title>broad-announce admin</title>
-    <script type="module" crossorigin src="/assets/index-B7DU3HvM.js"></script>
+    <script type="module" crossorigin src="/assets/index-WAbnQRoa.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/react-C4-CelXw.js">
     <link rel="modulepreload" crossorigin href="/assets/query-C_q0vuOo.js">
     <link rel="modulepreload" crossorigin href="/assets/ui-DcUwFLzq.js">
     <link rel="modulepreload" crossorigin href="/assets/forms-BRRx31Es.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C8nEVWVV.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-BODtBP6q.css">
   </head>
   <body class="h-full bg-background text-foreground">
     <div id="root" class="h-full"></div>

+ 15 - 0
cmd/authd/main.go

@@ -127,6 +127,21 @@ func run(logger *slog.Logger) error {
 	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/status", ad.RequireAuth(setSourceStatusHandler(ad, logger)))
 	mux.Handle("POST /v1/tenants/{id}/sources/{sid}/rotate-secrets", ad.RequireAuth(rotateSourceSecretsHandler(ad, logger)))
 
+	// M13b W3: Telegram bot CRUD. See cmd/authd/telegrambots.go.
+	//   GET    /v1/tenants/{id}/telegram/bots                       — super_admin only
+	//   POST   /v1/tenants/{id}/telegram/bots                       — super_admin only
+	//   GET    /v1/tenants/{id}/telegram/bots/{bid}                 — super_admin only
+	//   PATCH  /v1/tenants/{id}/telegram/bots/{bid}                 — super_admin only
+	//   POST   /v1/tenants/{id}/telegram/bots/{bid}/status          — super_admin only
+	//   POST   /v1/tenants/{id}/telegram/bots/{bid}/rotate-token    — super_admin only
+	// Bot token is write-only: server never returns the plaintext.
+	mux.Handle("GET /v1/tenants/{id}/telegram/bots", ad.RequireRole("super_admin")(listTelegramBotsHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/telegram/bots", ad.RequireRole("super_admin")(createTelegramBotHandler(ad, logger)))
+	mux.Handle("GET /v1/tenants/{id}/telegram/bots/{bid}", ad.RequireRole("super_admin")(getTelegramBotHandler(ad, logger)))
+	mux.Handle("PATCH /v1/tenants/{id}/telegram/bots/{bid}", ad.RequireRole("super_admin")(updateTelegramBotHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/telegram/bots/{bid}/status", ad.RequireRole("super_admin")(setTelegramBotStatusHandler(ad, logger)))
+	mux.Handle("POST /v1/tenants/{id}/telegram/bots/{bid}/rotate-token", ad.RequireRole("super_admin")(rotateTelegramBotTokenHandler(ad, logger)))
+
 	// Start in background, wait for signal, then graceful shutdown.
 	errCh := make(chan error, 1)
 	go func() { errCh <- srv.Start() }()

+ 380 - 0
cmd/authd/telegrambots.go

@@ -0,0 +1,380 @@
+// 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{
+			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)
+	}
+}

+ 592 - 0
internal/authd/telegrambots.go

@@ -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")
+}

+ 180 - 0
internal/authd/telegrambots_test.go

@@ -0,0 +1,180 @@
+// telegrambots_test.go — pure-Go tests for the input validators
+// and the format helpers on the telegram_bots store. The
+// DB-backed paths (Create, Update, SetStatus, List, RotateToken)
+// are exercised by scripts/m13b_w3_smoke.sh against a real
+// Postgres.
+
+package authd
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestValidTelegramBotID(t *testing.T) {
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		// valid (same shape as auth.tenants.slug / source id)
+		{"primary", true},
+		{"ops-foo", true},
+		{"a-b-c", true},
+		{strings.Repeat("a", 64), true},
+		// invalid
+		{"", false},
+		{"a", false},       // too short (1 char)
+		{"A", false},       // uppercase
+		{"-foo", false},    // leading dash
+		{"foo-", false},    // trailing dash
+		{"foo_bar", false}, // underscore
+		{"foo bar", false}, // space
+		{"foo.bar", false}, // dot
+		{strings.Repeat("a", 65), false},
+	}
+	for _, c := range cases {
+		if got := validTelegramBotID(c.in); got != c.want {
+			t.Errorf("validTelegramBotID(%q) = %v, want %v", c.in, got, c.want)
+		}
+	}
+}
+
+func TestValidBotTokenFormat(t *testing.T) {
+	goodSecret := strings.Repeat("a", 35)
+	goodSecretWith := "abc-DEF_123" + strings.Repeat("a", 26)
+	cases := []struct {
+		in   string
+		want bool
+	}{
+		// valid
+		{"12345678:" + goodSecret, true},
+		{"1:" + goodSecret, true},
+		{"1234567890:" + goodSecretWith, true},
+		// invalid
+		{"", false},
+		{":", false},
+		{":" + goodSecret, false},         // empty bot id
+		{"12345678", false},              // no colon
+		{"12345678:" + strings.Repeat("a", 34), false},  // secret too short
+		{"12345678:" + strings.Repeat("a", 36), true},   // secret one over (still ok per spec)
+		{"12345678:short", false},        // secret too short
+		{"abc:" + goodSecret, false},     // non-digit bot id
+		{"12345678:" + strings.Repeat("a", 35) + ":extra", false}, // extra colon
+		{"12345678:" + goodSecret + "!", false}, // bad char in secret
+		{"12345678:" + goodSecret + " with space", false},
+	}
+	for _, c := range cases {
+		if got := validBotTokenFormat(c.in); got != c.want {
+			t.Errorf("validBotTokenFormat(%q) = %v, want %v", c.in, got, c.want)
+		}
+	}
+}
+
+func TestCreateTelegramBotInput_Validate(t *testing.T) {
+	goodToken := "12345678:" + strings.Repeat("a", 35)
+	tooLongName := strings.Repeat("a", 201)
+	tooLongWelcome := strings.Repeat("a", 4097)
+	tooLongDesc := strings.Repeat("a", 501)
+	cases := []struct {
+		name    string
+		in      CreateTelegramBotInput
+		wantErr bool
+		errSub  string
+	}{
+		{
+			name: "ok",
+			in: CreateTelegramBotInput{
+				ID: "primary", Name: "Primary", BotToken: goodToken,
+			},
+			wantErr: false,
+		},
+		{
+			name: "ok with optional fields",
+			in: CreateTelegramBotInput{
+				ID: "primary", Name: "Primary", BotToken: goodToken,
+				WelcomeMessage: "Welcome to Acme alerts!",
+				DefaultSourceID: "primary",
+				Description:    "Main bot for ops",
+			},
+			wantErr: false,
+		},
+		{name: "bad id", in: CreateTelegramBotInput{ID: "Bad ID!", Name: "x", BotToken: goodToken}, wantErr: true, errSub: "id must match"},
+		{name: "empty name", in: CreateTelegramBotInput{ID: "primary", Name: "  ", BotToken: goodToken}, wantErr: true, errSub: "name is required"},
+		{name: "name too long", in: CreateTelegramBotInput{ID: "primary", Name: tooLongName, BotToken: goodToken}, wantErr: true, errSub: "name must be"},
+		{name: "bad token", in: CreateTelegramBotInput{ID: "primary", Name: "x", BotToken: "short"}, wantErr: true, errSub: "bot_token must match"},
+		{name: "welcome too long", in: CreateTelegramBotInput{ID: "primary", Name: "x", BotToken: goodToken, WelcomeMessage: tooLongWelcome}, wantErr: true, errSub: "welcome_message must be"},
+		{name: "default source id bad", in: CreateTelegramBotInput{ID: "primary", Name: "x", BotToken: goodToken, DefaultSourceID: "Bad ID!"}, wantErr: true, errSub: "default_source_id must match"},
+		{name: "default source id empty ok", in: CreateTelegramBotInput{ID: "primary", Name: "x", BotToken: goodToken, DefaultSourceID: ""}, wantErr: false},
+		{name: "description too long", in: CreateTelegramBotInput{ID: "primary", Name: "x", BotToken: goodToken, Description: tooLongDesc}, wantErr: true, errSub: "description must be"},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			err := c.in.Validate()
+			if c.wantErr {
+				if err == nil {
+					t.Fatalf("expected error containing %q, got nil", c.errSub)
+				}
+				if !strings.Contains(err.Error(), c.errSub) {
+					t.Fatalf("expected error containing %q, got %q", c.errSub, err.Error())
+				}
+			} else if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+		})
+	}
+}
+
+func TestUpdateTelegramBotInput_Validate(t *testing.T) {
+	name := "Renamed"
+	emptyName := " "
+	welcome := "Hello there"
+	tooLongWelcome := strings.Repeat("a", 4097)
+	defaultSrc := "primary"
+	badDefault := "Bad ID!"
+	desc := "Some description"
+	cases := []struct {
+		name    string
+		in      UpdateTelegramBotInput
+		wantErr bool
+		errSub  string
+	}{
+		{name: "empty (no-op)", in: UpdateTelegramBotInput{}, wantErr: false},
+		{name: "name change", in: UpdateTelegramBotInput{Name: &name}, wantErr: false},
+		{name: "name empty", in: UpdateTelegramBotInput{Name: &emptyName}, wantErr: true, errSub: "name cannot be empty"},
+		{name: "welcome ok", in: UpdateTelegramBotInput{WelcomeMessage: &welcome}, wantErr: false},
+		{name: "welcome empty ok (clear)", in: UpdateTelegramBotInput{WelcomeMessage: ptr("")}, wantErr: false},
+		{name: "welcome too long", in: UpdateTelegramBotInput{WelcomeMessage: &tooLongWelcome}, wantErr: true, errSub: "welcome_message must be"},
+		{name: "default source ok", in: UpdateTelegramBotInput{DefaultSourceID: &defaultSrc}, wantErr: false},
+		{name: "default source bad", in: UpdateTelegramBotInput{DefaultSourceID: &badDefault}, wantErr: true, errSub: "default_source_id must match"},
+		{name: "description ok", in: UpdateTelegramBotInput{Description: &desc}, wantErr: false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			err := c.in.Validate()
+			if c.wantErr {
+				if err == nil || !strings.Contains(err.Error(), c.errSub) {
+					t.Fatalf("expected error containing %q, got %v", c.errSub, err)
+				}
+			} else if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+		})
+	}
+}
+
+func TestHashBotToken(t *testing.T) {
+	plain := "12345678:" + strings.Repeat("a", 35)
+	h, err := hashBotToken(plain)
+	if err != nil {
+		t.Fatalf("hashBotToken: %v", err)
+	}
+	if h == "" {
+		t.Fatal("expected non-empty hash")
+	}
+	if h == plain {
+		t.Fatal("hash equals plaintext (bcrypt not applied)")
+	}
+	if !strings.HasPrefix(h, "$2a$") && !strings.HasPrefix(h, "$2b$") {
+		t.Errorf("expected bcrypt prefix ($2a$/$2b$), got %q", h[:10])
+	}
+}

+ 18 - 0
migrations/012_telegram_bot_fields.down.sql

@@ -0,0 +1,18 @@
+-- 012_telegram_bot_fields.down.sql
+--
+-- Reverts 012_telegram_bot_fields. Drops the columns added in W3.
+-- The original bot_token plaintext column is preserved; the
+-- trigger function is dropped because it was added by W3 and has
+-- no other consumers.
+
+DROP TRIGGER IF EXISTS trg_telegram_bots_touch_updated_at ON telegram_bots;
+DROP FUNCTION IF EXISTS telegram_bots_touch_updated_at();
+DROP INDEX IF EXISTS idx_telegram_bots_default_source;
+
+ALTER TABLE telegram_bots
+    DROP COLUMN IF EXISTS updated_at,
+    DROP COLUMN IF EXISTS last_rotated_at,
+    DROP COLUMN IF EXISTS description,
+    DROP COLUMN IF EXISTS default_source_id,
+    DROP COLUMN IF EXISTS welcome_message,
+    DROP COLUMN IF EXISTS bot_token_hash;

+ 84 - 0
migrations/012_telegram_bot_fields.up.sql

@@ -0,0 +1,84 @@
+-- 012_telegram_bot_fields.up.sql
+--
+-- M13b W3: extend public.telegram_bots so the admin UI can
+-- configure a per-bot welcome message and default source. Also
+-- adds a bcrypt hash of the bot_token so the UI can render a
+-- "configured / not configured" indicator without ever exposing
+-- the plaintext.
+--
+-- Why both columns?
+--   - bot_token            — TEXT, plaintext. telegramd reads
+--                            this directly to authenticate with
+--                            api.telegram.org. Keep it for now
+--                            so telegramd continues to work.
+--   - bot_token_hash       — TEXT, bcrypt. Added in W3 so the UI
+--                            can determine "configured" status
+--                            without exposing the plaintext. The
+--                            M11 security milestone will replace
+--                            bot_token entirely with an
+--                            AES-256-GCM-encrypted column and
+--                            add a sidecar to decrypt for
+--                            telegramd.
+--
+-- Why nullable bot_token_hash?
+--   Rows seeded by 004/seed_m3.sql have a plaintext bot_token
+--   but no hash yet. The next W3 rotation (or operator save via
+--   UI) will populate the hash. NULL = "unrotated since W3";
+--   the UI surfaces this as "Configured (legacy token)" with
+--   a one-click "Rotate to set hash" hint.
+--
+-- Other columns:
+--   welcome_message       — sent in response to /start (M13c wires
+--                            telegramd to use this; W3 just stores)
+--   default_source_id     — optional pointer to public.sources.id.
+--                            W3 sets it on the bot row; W4
+--                            (Smoke) verifies the FK shape. We do
+--                            NOT add a hard FK in this migration
+--                            because sources can be deleted out
+--                            from under the bot (admin flow); a
+--                            soft reference + ON DELETE SET NULL
+--                            would be correct, but we keep the
+--                            bot row even if the source goes away
+--                            (operator may want to point it at a
+--                            new source). v1.1 adds the FK +
+--                            a reconciliation job.
+--   description           — free-text label. Same shape as
+--                            sources.description.
+--   last_rotated_at       — set every time the token is written
+--                            (create OR rotate). UI shows it.
+--   updated_at            — bumped on any PATCH. Audit-friendly.
+--
+-- The down migration drops the new columns. The original
+-- bot_token plaintext column is preserved.
+
+ALTER TABLE telegram_bots
+    ADD COLUMN IF NOT EXISTS bot_token_hash     TEXT,
+    ADD COLUMN IF NOT EXISTS welcome_message    TEXT,
+    ADD COLUMN IF NOT EXISTS default_source_id  TEXT,
+    ADD COLUMN IF NOT EXISTS description        TEXT,
+    ADD COLUMN IF NOT EXISTS last_rotated_at     TIMESTAMPTZ,
+    ADD COLUMN IF NOT EXISTS updated_at         TIMESTAMPTZ NOT NULL DEFAULT now();
+
+-- updated_at trigger so any UPDATE bumps it without the app
+-- having to remember. Mirrors the auth.tenants.updated_at
+-- pattern from migration 010.
+CREATE OR REPLACE FUNCTION telegram_bots_touch_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+    NEW.updated_at = now();
+    RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_telegram_bots_touch_updated_at ON telegram_bots;
+CREATE TRIGGER trg_telegram_bots_touch_updated_at
+    BEFORE UPDATE ON telegram_bots
+    FOR EACH ROW
+    EXECUTE FUNCTION telegram_bots_touch_updated_at();
+
+-- Index on default_source_id so M13c routing lookups
+-- ("give me the bots that use this source") are fast even when
+-- the table is large. Partial index — most rows will have NULL
+-- default_source_id in v1.
+CREATE INDEX IF NOT EXISTS idx_telegram_bots_default_source
+    ON telegram_bots(default_source_id)
+    WHERE default_source_id IS NOT NULL;

+ 305 - 0
scripts/m13b_w3_smoke.sh

@@ -0,0 +1,305 @@
+#!/usr/bin/env bash
+# m13b_w3_smoke.sh — End-to-end smoke for the M13b W3 telegram bot CRUD.
+#
+# Walks through:
+#   1. authd /health
+#   2. login (super_admin) → access + refresh
+#   3. POST /v1/tenants (create a tenant to host a bot)
+#   4. GET /v1/tenants/{id}/telegram/bots (initially empty)
+#   5. POST /v1/tenants/{id}/telegram/bots (create with bot_token)
+#   6. GET  /v1/tenants/{id}/telegram/bots/{bid} (verify bot_token_set=true)
+#   7. GET /v1/tenants/{id}/telegram/bots (list has 1)
+#   8. PATCH /v1/tenants/{id}/telegram/bots/{bid} (change welcome_message)
+#   9. POST .../status (pause)
+#   10. POST .../status (activate)
+#   11. POST .../rotate-token (new token; bot_token_set still true)
+#   12. POST (duplicate id) → 409
+#   13. POST (bad id) → 400
+#   14. POST (bad token) → 400
+#   15. GET (no token field in response)
+#   16. tenant_admin tries telegram endpoints → 403 (super_admin only)
+#   17. Cleanup: archive the tenant
+#
+# Requires:
+#   - authd running on $BA_AUTHD_HTTP (default http://127.0.0.1:8804)
+#   - $BA_AUTHD_JWT_SECRET set
+#   - super_admin user in Postgres (scripts/bootstrap-super-admin.sh)
+#   - 012_telegram_bot_fields migration applied
+#
+# Run:
+#   bash scripts/m13b_w3_smoke.sh
+#
+# Exits 0 if all steps pass.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+AUTHD="${BA_AUTHD_HTTP:-http://127.0.0.1:8804}"
+SUPER_EMAIL="${BA_SMOKE_SUPER_EMAIL:-super@broad-announce.test}"
+SUPER_PASSWORD="${BA_SMOKE_SUPER_PASSWORD:-test-password-123}"
+DSN="${BA_POSTGRES_DSN:-${PG_DSN:-postgres://ba:ba@localhost:5432/ba?sslmode=disable}}"
+
+PASS=0
+FAIL=0
+RESULTS=()
+TENANT_SLUG="smoke-tg-$(date +%s)"
+TENANT_EMAIL="ops-${TENANT_SLUG}@smoke.test"
+TENANT_ADMIN_EMAIL="admin-${TENANT_SLUG}@smoke.test"
+TENANT_ADMIN_PASSWORD="smoke-test-password-1234"
+BOT_ID="primary"
+BOT_TOKEN="12345678:$(python3 -c "import secrets; print(secrets.token_hex(18)[:35])")"
+ROTATED_TOKEN="12345678:$(python3 -c "import secrets; print(secrets.token_hex(18)[:35])")"
+
+check() {
+  local name="$1"
+  local actual="$2"
+  local want="$3"
+  if [[ "$actual" == "$want" ]]; then
+    PASS=$((PASS+1))
+    RESULTS+=("OK   $name")
+  else
+    FAIL=$((FAIL+1))
+    RESULTS+=("FAIL $name (got $actual, want $want)")
+  fi
+}
+
+# JSON helper: extract a top-level field as string.
+json_field() {
+  python3 -c "import json,sys; d=json.load(sys.stdin); k='$2'.split('.'); v=d
+for kk in k:
+  v=v[kk] if isinstance(v,dict) else v[int(kk)]
+print(v if not isinstance(v,(list,dict,bool)) else json.dumps(v))" <<<"$1"
+}
+
+# -------------------------------------------------------------------
+# 1. health
+# -------------------------------------------------------------------
+status=$(curl -s -o /dev/null -w "%{http_code}" "$AUTHD/health")
+check "1. authd /health" "$status" "200"
+
+# -------------------------------------------------------------------
+# 2. login (super_admin)
+# -------------------------------------------------------------------
+login_body=$(curl -s -X POST "$AUTHD/v1/auth/login" \
+  -H 'Content-Type: application/json' \
+  -d "{\"email\":\"$SUPER_EMAIL\",\"password\":\"$SUPER_PASSWORD\"}")
+SUPER_TOKEN=$(json_field "$login_body" access_token)
+if [[ -z "$SUPER_TOKEN" ]]; then
+  echo "FATAL: super_admin login failed: $login_body"
+  exit 1
+fi
+check "2. super_admin login" "200" "200"
+
+# -------------------------------------------------------------------
+# 3. POST /v1/tenants (create a tenant to host a bot)
+# -------------------------------------------------------------------
+create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"slug\":\"$TENANT_SLUG\",\"display_name\":\"Smoke Telegram Tenant\",\"contact_email\":\"$TENANT_EMAIL\",\"rate_limit_per_sec\":5000,\"fcm_shared\":true}")
+create_code=$(echo "$create" | tail -1)
+create_body=$(echo "$create" | head -n -1)
+TENANT_ID=$(json_field "$create_body" id)
+check "3. POST /v1/tenants" "$create_code" "201"
+[[ -n "$TENANT_ID" ]] || { echo "FATAL: no tenant id"; exit 1; }
+echo "    new tenant: $TENANT_ID"
+
+# -------------------------------------------------------------------
+# 4. GET /v1/tenants/{id}/telegram/bots (initially empty)
+# -------------------------------------------------------------------
+list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots?limit=10")
+total=$(json_field "$list" total)
+check "4. GET /v1/tenants/{id}/telegram/bots (empty)" "$total" "0"
+
+# -------------------------------------------------------------------
+# 5. POST /v1/tenants/{id}/telegram/bots
+# -------------------------------------------------------------------
+create=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"$BOT_ID\",\"name\":\"Acme Ops Bot\",\"bot_token\":\"$BOT_TOKEN\",\"welcome_message\":\"Welcome!\",\"description\":\"smoke test\"}")
+create_code=$(echo "$create" | tail -1)
+create_body=$(echo "$create" | head -n -1)
+if [[ "$create_code" != "201" ]]; then
+  echo "FATAL: create bot failed ($create_code): $create_body"
+  exit 1
+fi
+BOT_ID_BACK=$(json_field "$create_body" id)
+BOT_TOKEN_SET=$(json_field "$create_body" bot_token_set)
+WELCOME=$(json_field "$create_body" welcome_message)
+check "5. POST /v1/tenants/{id}/telegram/bots" "$create_code" "201"
+check "5b. response id == $BOT_ID" "$BOT_ID_BACK" "$BOT_ID"
+check "5c. response bot_token_set == true" "$BOT_TOKEN_SET" "true"
+check "5d. response welcome_message" "$WELCOME" "Welcome!"
+
+# 5e. response MUST NOT include the plaintext bot_token.
+if echo "$create_body" | grep -q '"bot_token"'; then
+  check "5e. response does NOT contain bot_token" "present" "absent"
+else
+  check "5e. response does NOT contain bot_token" "absent" "absent"
+fi
+
+# -------------------------------------------------------------------
+# 6. GET /v1/tenants/{id}/telegram/bots/{bid}
+# -------------------------------------------------------------------
+detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID")
+detail_id=$(json_field "$detail" id)
+detail_status=$(json_field "$detail" status)
+detail_token_set=$(json_field "$detail" bot_token_set)
+check "6. GET bot id" "$detail_id" "$BOT_ID"
+check "6b. status == active" "$detail_status" "active"
+check "6c. bot_token_set == true" "$detail_token_set" "true"
+# 6d. detail MUST NOT include bot_token.
+if echo "$detail" | grep -q '"bot_token"'; then
+  check "6d. detail does NOT contain bot_token" "present" "absent"
+else
+  check "6d. detail does NOT contain bot_token" "absent" "absent"
+fi
+
+# -------------------------------------------------------------------
+# 7. GET /v1/tenants/{id}/telegram/bots (list has 1)
+# -------------------------------------------------------------------
+list=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots?limit=10")
+total=$(json_field "$list" total)
+check "7. GET telegram/bots (count)" "$total" "1"
+
+# -------------------------------------------------------------------
+# 8. PATCH /v1/tenants/{id}/telegram/bots/{bid}
+# -------------------------------------------------------------------
+patch=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"welcome_message":"Welcome to Acme!","default_source_id":"primary"}')
+check "8. PATCH telegram/bots/{bid}" "$patch" "200"
+detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID")
+detail_welcome=$(json_field "$detail" welcome_message)
+detail_default=$(json_field "$detail" default_source_id)
+check "8b. PATCH welcome_message persisted" "$detail_welcome" "Welcome to Acme!"
+check "8c. PATCH default_source_id persisted" "$detail_default" "primary"
+
+# -------------------------------------------------------------------
+# 9. POST .../status pause
+# -------------------------------------------------------------------
+pause=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"paused"}')
+check "9. POST .../status pause" "$pause" "200"
+detail=$(curl -s -H "Authorization: Bearer $SUPER_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID")
+detail_status=$(json_field "$detail" status)
+check "9b. status == paused" "$detail_status" "paused"
+
+# -------------------------------------------------------------------
+# 10. POST .../status activate
+# -------------------------------------------------------------------
+act=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"active"}')
+check "10. POST .../status activate" "$act" "200"
+
+# -------------------------------------------------------------------
+# 11. POST .../rotate-token
+# -------------------------------------------------------------------
+rot=$(curl -s -w "\n%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID/rotate-token" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"bot_token\":\"$ROTATED_TOKEN\"}")
+rot_code=$(echo "$rot" | tail -1)
+rot_body=$(echo "$rot" | head -n -1)
+check "11. POST .../rotate-token" "$rot_code" "200"
+rot_token_set=$(json_field "$rot_body" bot_token_set)
+rot_last=$(json_field "$rot_body" last_rotated_at)
+check "11b. rotated bot_token_set == true" "$rot_token_set" "true"
+if [[ -n "$rot_last" && "$rot_last" != "—" ]]; then
+  check "11c. last_rotated_at populated" "yes" "yes"
+else
+  check "11c. last_rotated_at populated" "$rot_last" "yes"
+fi
+# 11d. rotated response MUST NOT contain the new bot_token.
+if echo "$rot_body" | grep -q "$ROTATED_TOKEN"; then
+  check "11d. rotate response does NOT echo the token" "present" "absent"
+else
+  check "11d. rotate response does NOT echo the token" "absent" "absent"
+fi
+
+# -------------------------------------------------------------------
+# 12. POST duplicate id → 409
+# -------------------------------------------------------------------
+dup=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"$BOT_ID\",\"name\":\"Dup\",\"bot_token\":\"$BOT_TOKEN\"}")
+check "12. POST telegram/bots (dup id) \u2192 409" "$dup" "409"
+
+# -------------------------------------------------------------------
+# 13. POST bad id → 400
+# -------------------------------------------------------------------
+bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"Bad ID!\",\"name\":\"x\",\"bot_token\":\"$BOT_TOKEN\"}")
+check "13. POST telegram/bots (bad id) \u2192 400" "$bad" "400"
+
+# -------------------------------------------------------------------
+# 14. POST bad token → 400
+# -------------------------------------------------------------------
+bad=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"id":"secondary","name":"x","bot_token":"not-a-token"}')
+check "14. POST telegram/bots (bad token) \u2192 400" "$bad" "400"
+
+# -------------------------------------------------------------------
+# 15. tenant_admin (created below) is FORBIDDEN on telegram endpoints
+# -------------------------------------------------------------------
+export PGPASSWORD="$(echo "$DSN" | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')"
+HASH=$(python3 -c "
+import bcrypt
+print(bcrypt.hashpw(b'${TENANT_ADMIN_PASSWORD}', bcrypt.gensalt(rounds=10)).decode())
+")
+psql "$DSN" -v ON_ERROR_STOP=0 -X -q -c "
+INSERT INTO auth.users (tenant_id, email, role, status, password_hash)
+SELECT id, '${TENANT_ADMIN_EMAIL}', 'tenant_admin', 'active', '${HASH}'
+FROM auth.tenants WHERE slug = '${TENANT_SLUG}'
+ON CONFLICT (email, tenant_id) WHERE tenant_id IS NOT NULL DO UPDATE SET password_hash = EXCLUDED.password_hash, status = 'active';
+" >/dev/null
+ta_login=$(curl -s -X POST "$AUTHD/v1/auth/login" \
+  -H 'Content-Type: application/json' \
+  -d "{\"email\":\"$TENANT_ADMIN_EMAIL\",\"password\":\"$TENANT_ADMIN_PASSWORD\"}")
+TA_TOKEN=$(json_field "$ta_login" access_token)
+if [[ -z "$TA_TOKEN" ]]; then
+  echo "FATAL: tenant_admin login failed: $ta_login"
+  exit 1
+fi
+check "15. tenant_admin login" "200" "200"
+ta_list=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots")
+check "15a. tenant_admin GET telegram/bots \u2192 403" "$ta_list" "403"
+ta_post=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots" \
+  -H "Authorization: Bearer $TA_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d "{\"id\":\"other\",\"name\":\"x\",\"bot_token\":\"$BOT_TOKEN\"}")
+check "15b. tenant_admin POST telegram/bots \u2192 403" "$ta_post" "403"
+ta_get=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $TA_TOKEN" "$AUTHD/v1/tenants/$TENANT_ID/telegram/bots/$BOT_ID")
+check "15c. tenant_admin GET telegram/bots/{bid} \u2192 403" "$ta_get" "403"
+
+# -------------------------------------------------------------------
+# 16. Cleanup: archive the tenant
+# -------------------------------------------------------------------
+arc=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$AUTHD/v1/tenants/$TENANT_ID/status" \
+  -H "Authorization: Bearer $SUPER_TOKEN" \
+  -H 'Content-Type: application/json' \
+  -d '{"status":"archived"}')
+check "16. cleanup: archive tenant" "$arc" "200"
+
+# -------------------------------------------------------------------
+# Summary
+# -------------------------------------------------------------------
+echo
+for r in "${RESULTS[@]}"; do echo "  $r"; done
+echo
+echo "PASS=$PASS FAIL=$FAIL"
+if [[ $FAIL -gt 0 ]]; then
+  exit 1
+fi
+exit 0

+ 3 - 3
web/src/components/layout/sidebar.tsx

@@ -10,7 +10,7 @@ import {
 } from 'lucide-react';
 import { cn } from '@/lib/utils';
 import { useAuth } from '@/lib/auth-context';
-import { canManageCompanies, canManageSources, canViewDLQ } from '@/lib/scope';
+import { canManageCompanies, canManageSources, canManageTelegram, canViewDLQ } from '@/lib/scope';
 
 interface NavItem {
   to: string;
@@ -23,7 +23,7 @@ interface NavItem {
 const NAV: NavItem[] = [
   { to: '/companies', label: 'Companies', icon: Building2, show: (u) => canManageCompanies(u?.role) },
   { to: '/sources', label: 'Sources', icon: Radio, show: (u) => canManageSources(u?.role) },
-  { to: '/telegram', label: 'Telegram', icon: Send, show: (u) => canManageSources(u?.role) },
+  { to: '/telegram', label: 'Telegram', icon: Send, show: (u) => canManageTelegram(u?.role) },
   { to: '/tail', label: 'Live tail', icon: Activity },
   { to: '/dlq', label: 'DLQ', icon: Inbox, show: (u) => canViewDLQ(u?.role), badge: 'M13c' },
   { to: '/audit', label: 'Audit log', icon: ScrollText, badge: 'M13c' },
@@ -64,7 +64,7 @@ export function Sidebar() {
       <div className="border-t p-3 text-xs text-muted-foreground">
         <div className="flex items-center gap-1.5">
           <ShieldCheck className="h-3.5 w-3.5" />
-          <span>M13b W0 · SPA shell</span>
+          <span>M13b W3 · SPA shell</span>
         </div>
       </div>
     </aside>

+ 131 - 0
web/src/features/telegram/api.ts

@@ -0,0 +1,131 @@
+/**
+ * TanStack Query hooks for the /v1/tenants/{id}/telegram/bots/*
+ * endpoints.
+ *
+ * The hooks are feature-scoped: callers (list, create-dialog,
+ * detail-page) pull these and don't talk to fetchWithAuth
+ * directly. That way the cache is shared across views and the
+ * query keys are predictable.
+ *
+ * Cache key strategy: ['telegram-bots', 'list', tenantId, params]
+ * for the list, ['telegram-bots', 'detail', tenantId, botId] for
+ * the detail. tenantId is part of the key (not just the URL) so
+ * the list cache for tenant A doesn't leak when the user
+ * navigates to tenant B.
+ */
+
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { ApiError, apiGet, apiSend } from '@/lib/api';
+import type {
+  CreateTelegramBotInput,
+  ListTelegramBotsResponse,
+  RotateTelegramBotTokenInput,
+  TelegramBot,
+  UpdateTelegramBotInput,
+} from './types';
+
+export interface ListTelegramBotsParams {
+  q?: string;
+  status?: string;
+  limit?: number;
+  offset?: number;
+}
+
+const KEYS = {
+  list: (tenantId: string | undefined, params: ListTelegramBotsParams) =>
+    ['telegram-bots', 'list', tenantId, params] as const,
+  detail: (tenantId: string | undefined, botId: string | undefined) =>
+    ['telegram-bots', 'detail', tenantId, botId] as const,
+};
+
+function buildListQuery(tenantId: string, params: ListTelegramBotsParams): string {
+  const u = new URLSearchParams();
+  if (params.q) u.set('q', params.q);
+  if (params.status) u.set('status', params.status);
+  if (params.limit) u.set('limit', String(params.limit));
+  if (params.offset) u.set('offset', String(params.offset));
+  const s = u.toString();
+  return s
+    ? `/v1/tenants/${tenantId}/telegram/bots?${s}`
+    : `/v1/tenants/${tenantId}/telegram/bots`;
+}
+
+export function useTelegramBotsList(tenantId: string | undefined, params: ListTelegramBotsParams) {
+  return useQuery({
+    queryKey: KEYS.list(tenantId, params),
+    queryFn: () =>
+      apiGet<ListTelegramBotsResponse>(buildListQuery(tenantId as string, params)),
+    enabled: Boolean(tenantId),
+    staleTime: 15_000,
+  });
+}
+
+export function useTelegramBot(tenantId: string | undefined, botId: string | undefined) {
+  return useQuery({
+    queryKey: KEYS.detail(tenantId, botId),
+    queryFn: () => apiGet<TelegramBot>(`/v1/tenants/${tenantId}/telegram/bots/${botId}`),
+    enabled: Boolean(tenantId) && Boolean(botId),
+  });
+}
+
+export function useCreateTelegramBot(tenantId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (input: CreateTelegramBotInput) =>
+      apiSend<TelegramBot>('POST', `/v1/tenants/${tenantId}/telegram/bots`, input),
+    onSuccess: () => {
+      void qc.invalidateQueries({ queryKey: ['telegram-bots', 'list', tenantId] });
+    },
+  });
+}
+
+export function useUpdateTelegramBot(tenantId: string, botId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (input: UpdateTelegramBotInput) =>
+      apiSend<TelegramBot>('PATCH', `/v1/tenants/${tenantId}/telegram/bots/${botId}`, input),
+    onSuccess: (bot) => {
+      qc.setQueryData(KEYS.detail(tenantId, botId), bot);
+      void qc.invalidateQueries({ queryKey: ['telegram-bots', 'list', tenantId] });
+    },
+  });
+}
+
+export function useSetTelegramBotStatus(tenantId: string, botId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (status: 'active' | 'paused') =>
+      apiSend<TelegramBot>('POST', `/v1/tenants/${tenantId}/telegram/bots/${botId}/status`, {
+        status,
+      }),
+    onSuccess: (bot) => {
+      qc.setQueryData(KEYS.detail(tenantId, botId), bot);
+      void qc.invalidateQueries({ queryKey: ['telegram-bots', 'list', tenantId] });
+    },
+  });
+}
+
+export function useRotateTelegramBotToken(tenantId: string, botId: string) {
+  const qc = useQueryClient();
+  return useMutation({
+    mutationFn: (input: RotateTelegramBotTokenInput) =>
+      apiSend<TelegramBot>(
+        'POST',
+        `/v1/tenants/${tenantId}/telegram/bots/${botId}/rotate-token`,
+        input,
+      ),
+    onSuccess: (bot) => {
+      qc.setQueryData(KEYS.detail(tenantId, botId), bot);
+      void qc.invalidateQueries({ queryKey: ['telegram-bots', 'list', tenantId] });
+    },
+  });
+}
+
+export function getErrorMessage(err: unknown): string {
+  if (err instanceof ApiError) {
+    const body = err.body as { error?: string; message?: string } | null;
+    return body?.message ?? body?.error ?? err.message;
+  }
+  if (err instanceof Error) return err.message;
+  return 'Unknown error';
+}

+ 255 - 0
web/src/features/telegram/create-dialog.tsx

@@ -0,0 +1,255 @@
+/**
+ * Create-telegram-bot dialog. Renders as a Radix Dialog triggered
+ * by a Button. The form collects the bot fields, including the
+ * one-time bot_token paste.
+ *
+ * The bot_token is write-only: the UI shows it as a password
+ * field and never reads it back from the server. The operator
+ * pastes a token they got from @BotFather; the server stores
+ * the plaintext (so telegramd can use it) and bcrypt-hashes it
+ * for the `bot_token_hash` column. After a successful create
+ * the form clears the token field.
+ *
+ * Renders nothing if the user lacks create-bot permission
+ * (super_admin only — see canManageTelegram in
+ * web/src/lib/scope.ts).
+ */
+
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { Eye, EyeOff, Plus, Send } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+  DialogTrigger,
+} from '@/components/ui/dialog';
+
+import { getErrorMessage, useCreateTelegramBot } from './api';
+
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
+// Telegram bot tokens look like `<bot_id>:<secret>` where
+// bot_id is decimal digits and secret is 35+ [A-Za-z0-9_-]
+// chars. We accept the same shape documented in
+// M13b_PLAN §2.3.
+const TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
+
+const formSchema = z.object({
+  id: z
+    .string()
+    .min(2, 'Bot id must be 2–64 characters')
+    .max(64, 'Bot id must be 2–64 characters')
+    .regex(ID_RE, 'Lowercase letters, digits, and dashes only'),
+  name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
+  bot_token: z
+    .string()
+    .min(1, 'Bot token is required')
+    .regex(TOKEN_RE, 'Token must look like 12345678:AbCdEfGh... (35+ chars after the colon)'),
+  welcome_message: z
+    .string()
+    .max(4096, 'Welcome message must be \u2264 4096 characters')
+    .optional()
+    .or(z.literal('')),
+  default_source_id: z
+    .string()
+    .regex(ID_RE, 'Lowercase letters, digits, and dashes only')
+    .optional()
+    .or(z.literal('')),
+  description: z
+    .string()
+    .max(500, 'Description must be \u2264 500 characters')
+    .optional()
+    .or(z.literal('')),
+});
+
+type FormValues = z.infer<typeof formSchema>;
+
+const EMPTY_DEFAULTS: FormValues = {
+  id: '',
+  name: '',
+  bot_token: '',
+  welcome_message: '',
+  default_source_id: '',
+  description: '',
+};
+
+export function CreateTelegramBotDialog({ tenantId }: { tenantId: string }) {
+  const [open, setOpen] = useState(false);
+  const [showToken, setShowToken] = useState(false);
+  const create = useCreateTelegramBot(tenantId);
+
+  const form = useForm<FormValues>({
+    resolver: zodResolver(formSchema),
+    defaultValues: EMPTY_DEFAULTS,
+  });
+
+  // Reset form when the dialog opens.
+  useEffect(() => {
+    if (open) form.reset(EMPTY_DEFAULTS);
+  }, [open, form]);
+
+  const onSubmit = form.handleSubmit(async (values) => {
+    try {
+      const created = await create.mutateAsync({
+        id: values.id.trim(),
+        name: values.name.trim(),
+        bot_token: values.bot_token.trim(),
+        welcome_message: values.welcome_message || undefined,
+        default_source_id: values.default_source_id || undefined,
+        description: values.description || undefined,
+      });
+      toast.success(`Bot "${created.name}" created.`);
+      // Clear the token field on success; it has been stored
+      // server-side and will never be re-shown.
+      form.setValue('bot_token', '');
+      setShowToken(false);
+      setOpen(false);
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  });
+
+  return (
+    <Dialog open={open} onOpenChange={setOpen}>
+      <DialogTrigger asChild>
+        <Button>
+          <Plus className="mr-2 h-4 w-4" />
+          New bot
+        </Button>
+      </DialogTrigger>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <Send className="h-4 w-4" />
+            New Telegram bot
+          </DialogTitle>
+          <DialogDescription>
+            Paste a bot token from <strong>@BotFather</strong>. The token is
+            stored encrypted server-side and never shown again after this
+            dialog closes. telegramd will pick it up on its next reload.
+          </DialogDescription>
+        </DialogHeader>
+        <form onSubmit={onSubmit} className="flex flex-col gap-4">
+          <div className="grid grid-cols-2 gap-3">
+            <div className="col-span-1 flex flex-col gap-1">
+              <Label htmlFor="bot-id">ID</Label>
+              <Input id="bot-id" placeholder="primary" {...form.register('id')} />
+              {form.formState.errors.id ? (
+                <p className="text-xs text-destructive">{form.formState.errors.id.message}</p>
+              ) : null}
+            </div>
+            <div className="col-span-1 flex flex-col gap-1">
+              <Label htmlFor="bot-name">Name</Label>
+              <Input id="bot-name" placeholder="Acme Ops" {...form.register('name')} />
+              {form.formState.errors.name ? (
+                <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
+              ) : null}
+            </div>
+          </div>
+          <div className="flex flex-col gap-1">
+            <Label htmlFor="bot-token">Bot token</Label>
+            <div className="flex items-center gap-2">
+              <Input
+                id="bot-token"
+                type={showToken ? 'text' : 'password'}
+                placeholder="123456789:AbCdEfGhIjKlMnOpQrStUvWxYz-12345"
+                autoComplete="off"
+                spellCheck={false}
+                {...form.register('bot_token')}
+              />
+              <Button
+                type="button"
+                variant="ghost"
+                size="sm"
+                onClick={() => setShowToken((v) => !v)}
+                aria-label={showToken ? 'Hide token' : 'Show token'}
+              >
+                {showToken ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
+              </Button>
+            </div>
+            {form.formState.errors.bot_token ? (
+              <p className="text-xs text-destructive">
+                {form.formState.errors.bot_token.message}
+              </p>
+            ) : (
+              <p className="text-xs text-muted-foreground">
+                Get this from @BotFather in Telegram. The shape is
+                <code className="ml-1 rounded bg-muted px-1 py-0.5 text-[10px]">
+                  &lt;bot_id&gt;:&lt;secret&gt;
+                </code>
+                .
+              </p>
+            )}
+          </div>
+          <div className="flex flex-col gap-1">
+            <Label htmlFor="bot-default-source">Default source ID (optional)</Label>
+            <Input
+              id="bot-default-source"
+              placeholder="primary"
+              {...form.register('default_source_id')}
+            />
+            <p className="text-xs text-muted-foreground">
+              If set, the bot will be the default for alerts coming from this source.
+            </p>
+            {form.formState.errors.default_source_id ? (
+              <p className="text-xs text-destructive">
+                {form.formState.errors.default_source_id.message}
+              </p>
+            ) : null}
+          </div>
+          <div className="flex flex-col gap-1">
+            <Label htmlFor="bot-welcome">Welcome message (optional)</Label>
+            <Textarea
+              id="bot-welcome"
+              rows={2}
+              placeholder="Welcome to Acme alerts! Reply /help to see available commands."
+              {...form.register('welcome_message')}
+            />
+            <p className="text-xs text-muted-foreground">
+              Sent in response to /start. M13c will wire this into telegramd.
+            </p>
+            {form.formState.errors.welcome_message ? (
+              <p className="text-xs text-destructive">
+                {form.formState.errors.welcome_message.message}
+              </p>
+            ) : null}
+          </div>
+          <div className="flex flex-col gap-1">
+            <Label htmlFor="bot-desc">Description (optional)</Label>
+            <Textarea
+              id="bot-desc"
+              rows={2}
+              placeholder="What does this bot do?"
+              {...form.register('description')}
+            />
+            {form.formState.errors.description ? (
+              <p className="text-xs text-destructive">
+                {form.formState.errors.description.message}
+              </p>
+            ) : null}
+          </div>
+
+          <DialogFooter>
+            <Button type="button" variant="ghost" onClick={() => setOpen(false)}>
+              Cancel
+            </Button>
+            <Button type="submit" disabled={create.isPending}>
+              {create.isPending ? 'Creating…' : 'Create bot'}
+            </Button>
+          </DialogFooter>
+        </form>
+      </DialogContent>
+    </Dialog>
+  );
+}

+ 484 - 0
web/src/features/telegram/detail-page.tsx

@@ -0,0 +1,484 @@
+/**
+ * Detail view for a single telegram bot. URL: /telegram/{tenantId}/{botId}.
+ *
+ * Layout:
+ *   - Header: name + status badge + back link
+ *   - Edit form (name, welcome_message, default_source_id, description)
+ *   - Status actions: Pause / Activate
+ *   - Bot token card: shows "Configured" / "Not set", with a
+ *     "Rotate token" button that opens a one-time modal. The
+ *     operator pastes a new token from @BotFather; the server
+ *     stores it and bcrypt-hashes it. The plaintext is never
+ *     echoed back; the operator already has it.
+ *   - Metadata panel: created, last_seen, last_rotated, etc.
+ */
+
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+import { ArrowLeft, Eye, EyeOff, KeyRound, Send, ShieldCheck } from 'lucide-react';
+import { toast } from 'sonner';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Textarea } from '@/components/ui/textarea';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog';
+
+import {
+  getErrorMessage,
+  useRotateTelegramBotToken,
+  useSetTelegramBotStatus,
+  useTelegramBot,
+  useUpdateTelegramBot,
+} from './api';
+import {
+  StatusBadge,
+  TokenSetBadge,
+  formatDate,
+  formatDateTime,
+} from './format';
+import type { TelegramBot, TelegramBotStatus } from './types';
+
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
+const TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
+
+const formSchema = z.object({
+  name: z.string().min(1, 'Name is required').max(200, 'Name must be \u2264 200 characters'),
+  welcome_message: z
+    .string()
+    .max(4096, 'Welcome message must be \u2264 4096 characters')
+    .optional()
+    .or(z.literal('')),
+  default_source_id: z
+    .string()
+    .regex(ID_RE, 'Lowercase letters, digits, and dashes only')
+    .optional()
+    .or(z.literal('')),
+  description: z
+    .string()
+    .max(500, 'Description must be \u2264 500 characters')
+    .optional()
+    .or(z.literal('')),
+});
+
+const EMPTY_DEFAULTS = {
+  name: '',
+  welcome_message: '',
+  default_source_id: '',
+  description: '',
+};
+
+export function TelegramBotDetailPage() {
+  const params = useParams();
+  const navigate = useNavigate();
+  const tenantId = params.id ?? '';
+  const botId = params.bid ?? '';
+
+  const botQ = useTelegramBot(tenantId, botId);
+  const update = useUpdateTelegramBot(tenantId, botId);
+  const setStatus = useSetTelegramBotStatus(tenantId, botId);
+  const rotate = useRotateTelegramBotToken(tenantId, botId);
+
+  const [rotateOpen, setRotateOpen] = useState(false);
+
+  const form = useForm({
+    resolver: zodResolver(formSchema),
+    defaultValues: EMPTY_DEFAULTS,
+  });
+
+  useEffect(() => {
+    const b = botQ.data;
+    if (!b) return;
+    form.setValue('name', b.name);
+    form.setValue('welcome_message', b.welcome_message ?? '');
+    form.setValue('default_source_id', b.default_source_id ?? '');
+    form.setValue('description', b.description ?? '');
+  }, [botQ.data, form]);
+
+  const onSubmit = form.handleSubmit(async (values) => {
+    try {
+      await update.mutateAsync({
+        name: values.name.trim(),
+        welcome_message: values.welcome_message || undefined,
+        default_source_id: values.default_source_id || undefined,
+        description: values.description || undefined,
+      });
+      toast.success('Bot updated.');
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  });
+
+  const onSetStatus = async (next: TelegramBotStatus) => {
+    try {
+      await setStatus.mutateAsync(next);
+      toast.success(`Bot ${next}.`);
+    } catch (err) {
+      toast.error(getErrorMessage(err));
+    }
+  };
+
+  if (botQ.isError) {
+    return (
+      <div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
+        <p className="font-medium">Failed to load bot.</p>
+        <p className="mt-1 text-xs opacity-80">
+          {botQ.error instanceof Error ? botQ.error.message : 'Unknown error'}
+        </p>
+      </div>
+    );
+  }
+  if (!botQ.data) {
+    return (
+      <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+        Loading…
+      </div>
+    );
+  }
+  const b = botQ.data;
+
+  return (
+    <div className="flex flex-col gap-4">
+      <div className="flex items-center gap-2">
+        <Button variant="ghost" size="sm" onClick={() => navigate(`/telegram/${tenantId}`)}>
+          <ArrowLeft className="mr-1 h-3 w-3" />
+          Telegram bots
+        </Button>
+      </div>
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <div className="flex items-center gap-2">
+            <Send className="h-5 w-5 text-muted-foreground" />
+            <h1 className="text-2xl font-semibold tracking-tight">{b.name}</h1>
+            <StatusBadge status={b.status} />
+          </div>
+          <p className="mt-1 text-sm text-muted-foreground">
+            <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{b.id}</code>
+            <span className="mx-2">\u00b7</span>
+            company
+            <code className="ml-1 rounded bg-muted px-1.5 py-0.5 text-xs">{b.company_id}</code>
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {b.status === 'active' ? (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => onSetStatus('paused')}
+              disabled={setStatus.isPending}
+            >
+              Pause
+            </Button>
+          ) : (
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => onSetStatus('active')}
+              disabled={setStatus.isPending}
+            >
+              Activate
+            </Button>
+          )}
+        </div>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>Configuration</CardTitle>
+          <CardDescription>
+            Edit the bot's name, welcome message, default source, and description.
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={onSubmit} className="flex flex-col gap-3">
+            <div className="flex flex-col gap-1">
+              <Label htmlFor="d-name">Name</Label>
+              <Input id="d-name" {...form.register('name')} />
+              {form.formState.errors.name ? (
+                <p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
+              ) : null}
+            </div>
+            <div className="grid grid-cols-2 gap-3">
+              <div className="col-span-1 flex flex-col gap-1">
+                <Label htmlFor="d-default-source">Default source ID</Label>
+                <Input
+                  id="d-default-source"
+                  placeholder="primary"
+                  {...form.register('default_source_id')}
+                />
+                {form.formState.errors.default_source_id ? (
+                  <p className="text-xs text-destructive">
+                    {form.formState.errors.default_source_id.message}
+                  </p>
+                ) : null}
+              </div>
+              <div className="col-span-1 flex flex-col gap-1">
+                <Label htmlFor="d-desc">Description</Label>
+                <Input id="d-desc" {...form.register('description')} />
+                {form.formState.errors.description ? (
+                  <p className="text-xs text-destructive">
+                    {form.formState.errors.description.message}
+                  </p>
+                ) : null}
+              </div>
+            </div>
+            <div className="flex flex-col gap-1">
+              <Label htmlFor="d-welcome">Welcome message</Label>
+              <Textarea id="d-welcome" rows={3} {...form.register('welcome_message')} />
+              <p className="text-xs text-muted-foreground">
+                Sent in response to /start. M13c wires this into telegramd.
+              </p>
+              {form.formState.errors.welcome_message ? (
+                <p className="text-xs text-destructive">
+                  {form.formState.errors.welcome_message.message}
+                </p>
+              ) : null}
+            </div>
+            <div className="flex items-center gap-2 pt-1">
+              <Button type="submit" disabled={update.isPending}>
+                {update.isPending ? 'Saving…' : 'Save changes'}
+              </Button>
+            </div>
+          </form>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <KeyRound className="h-4 w-4" />
+            Bot token
+          </CardTitle>
+          <CardDescription>
+            The plaintext token is never returned after save. The server stores
+            the hash so it can render "configured"; telegramd reads the
+            plaintext from the same row. Use rotate to swap to a new token
+            (e.g. after revoking the old one in @BotFather).
+          </CardDescription>
+        </CardHeader>
+        <CardContent>
+          <div className="grid grid-cols-2 gap-3 text-sm">
+            <SecretIndicator label="Bot token" set={b.bot_token_set} />
+            <SecretIndicator label="Last rotated" text={formatDateTime(b.last_rotated_at)} />
+          </div>
+          <div className="mt-4 flex items-center gap-2">
+            <Button
+              variant="outline"
+              size="sm"
+              onClick={() => setRotateOpen(true)}
+              disabled={rotate.isPending}
+            >
+              {rotate.isPending ? 'Rotating…' : 'Rotate token'}
+            </Button>
+            <span className="text-xs text-muted-foreground">
+              The new token is set on the row immediately. telegramd picks it
+              up on its next reload (or sooner with a future notify channel).
+            </span>
+          </div>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle className="flex items-center gap-2">
+            <ShieldCheck className="h-4 w-4" />
+            Security notes
+          </CardTitle>
+        </CardHeader>
+        <CardContent className="text-sm text-muted-foreground">
+          <ul className="ml-5 list-disc space-y-1">
+            <li>
+              Bot tokens are write-only. The server never returns the plaintext
+              — only a <code>bot_token_set</code> boolean.
+            </li>
+            <li>
+              The plaintext is stored alongside its bcrypt hash so telegramd
+              can use it. The M11 security milestone will replace this with
+              AES-256-GCM encryption and a decryption sidecar.
+            </li>
+            <li>
+              Every state change (create, update, status, rotate) writes an
+              audit row visible in the Audit log.
+            </li>
+          </ul>
+        </CardContent>
+      </Card>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>Metadata</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
+            <dt className="text-muted-foreground">Bot ID</dt>
+            <dd>
+              <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{b.id}</code>
+            </dd>
+            <dt className="text-muted-foreground">Company</dt>
+            <dd>
+              <code className="rounded bg-muted px-1.5 py-0.5 text-xs">{b.company_id}</code>
+            </dd>
+            <dt className="text-muted-foreground">Status</dt>
+            <dd>
+              <StatusBadge status={b.status} /> <TokenSetBadge set={b.bot_token_set} />
+            </dd>
+            <dt className="text-muted-foreground">Last rotated</dt>
+            <dd>{formatDateTime(b.last_rotated_at)}</dd>
+            <dt className="text-muted-foreground">Last seen</dt>
+            <dd>{formatDateTime(b.last_seen_at)}</dd>
+            <dt className="text-muted-foreground">Created</dt>
+            <dd>{formatDate(b.created_at)}</dd>
+            <dt className="text-muted-foreground">Updated</dt>
+            <dd>{formatDateTime(b.updated_at)}</dd>
+          </dl>
+        </CardContent>
+      </Card>
+
+      <RotateTokenDialog
+        open={rotateOpen}
+        onOpenChange={setRotateOpen}
+        onSubmit={async (token) => {
+          try {
+            await rotate.mutateAsync({ bot_token: token });
+            toast.success('Token rotated.');
+            setRotateOpen(false);
+          } catch (err) {
+            toast.error(getErrorMessage(err));
+          }
+        }}
+        isPending={rotate.isPending}
+      />
+    </div>
+  );
+}
+
+function SecretIndicator({
+  label,
+  set,
+  text,
+}: {
+  label: string;
+  set?: boolean;
+  text?: string;
+}) {
+  return (
+    <div className="flex items-center justify-between rounded-md border bg-muted/20 p-3">
+      <div className="flex flex-col">
+        <span className="text-xs text-muted-foreground">{label}</span>
+        <span className="text-sm font-medium">{text ?? (set ? 'Configured' : 'Not set')}</span>
+      </div>
+      {set !== undefined ? (
+        <span
+          className={
+            set
+              ? 'inline-flex h-2 w-2 rounded-full bg-emerald-500'
+              : 'inline-flex h-2 w-2 rounded-full bg-amber-400'
+          }
+          aria-label={set ? 'set' : 'not set'}
+        />
+      ) : null}
+    </div>
+  );
+}
+
+function RotateTokenDialog({
+  open,
+  onOpenChange,
+  onSubmit,
+  isPending,
+}: {
+  open: boolean;
+  onOpenChange: (v: boolean) => void;
+  onSubmit: (token: string) => Promise<void>;
+  isPending: boolean;
+}) {
+  const [token, setToken] = useState('');
+  const [show, setShow] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (open) {
+      setToken('');
+      setShow(false);
+      setError(null);
+    }
+  }, [open]);
+
+  const submit = async () => {
+    const t = token.trim();
+    if (!TOKEN_RE.test(t)) {
+      setError('Token must look like 12345678:AbCdEfGh... (35+ chars after the colon).');
+      return;
+    }
+    setError(null);
+    await onSubmit(t);
+  };
+
+  return (
+    <Dialog open={open} onOpenChange={onOpenChange}>
+      <DialogContent className="sm:max-w-lg">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <ShieldCheck className="h-5 w-5 text-amber-500" />
+            Rotate bot token
+          </DialogTitle>
+          <DialogDescription>
+            Paste the new token from <strong>@BotFather</strong>. The server
+            stores the plaintext (so telegramd can use it) and bcrypt-hashes
+            it for the audit-friendly <code>bot_token_hash</code> column. The
+            plaintext is <strong>never</strong> echoed back.
+          </DialogDescription>
+        </DialogHeader>
+        <div className="flex flex-col gap-2">
+          <Label htmlFor="rotate-token" className="text-xs">
+            New bot token
+          </Label>
+          <div className="flex items-center gap-2">
+            <Input
+              id="rotate-token"
+              type={show ? 'text' : 'password'}
+              autoComplete="off"
+              spellCheck={false}
+              placeholder="123456789:AbCdEfGhIjKlMnOpQrStUvWxYz-12345"
+              value={token}
+              onChange={(e) => setToken(e.target.value)}
+            />
+            <Button
+              type="button"
+              variant="ghost"
+              size="sm"
+              onClick={() => setShow((v) => !v)}
+              aria-label={show ? 'Hide' : 'Show'}
+            >
+              {show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
+            </Button>
+          </div>
+          {error ? <p className="text-xs text-destructive">{error}</p> : null}
+        </div>
+        <DialogFooter>
+          <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
+            Cancel
+          </Button>
+          <Button type="button" onClick={submit} disabled={isPending}>
+            {isPending ? 'Rotating…' : 'Rotate token'}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+// referenced in the props below to keep tsc happy if we ever
+// drop the import; do not remove.
+export type _TelegramBotRef = TelegramBot;

+ 78 - 0
web/src/features/telegram/format.tsx

@@ -0,0 +1,78 @@
+/**
+ * Display formatters for the Telegram bots feature. Kept as
+ * pure functions / components so they're easy to test
+ * independently of React.
+ */
+
+import { Badge } from '@/components/ui/badge';
+import type { TelegramBotStatus } from './types';
+
+const STATUS_LABEL: Record<TelegramBotStatus, string> = {
+  active: 'Active',
+  paused: 'Paused',
+};
+
+const STATUS_VARIANT: Record<TelegramBotStatus, 'success' | 'warning'> = {
+  active: 'success',
+  paused: 'warning',
+};
+
+export function statusLabel(s: TelegramBotStatus): string {
+  return STATUS_LABEL[s] ?? s;
+}
+
+export function statusVariant(s: TelegramBotStatus): 'success' | 'warning' {
+  return STATUS_VARIANT[s] ?? 'warning';
+}
+
+export function StatusBadge({ status }: { status: TelegramBotStatus }) {
+  return <Badge variant={statusVariant(status)}>{statusLabel(status)}</Badge>;
+}
+
+export function tokenSetLabel(set: boolean): string {
+  return set ? 'Configured' : 'Not set';
+}
+
+export function tokenSetVariant(
+  set: boolean,
+): 'success' | 'warning' {
+  return set ? 'success' : 'warning';
+}
+
+export function TokenSetBadge({ set }: { set: boolean }) {
+  return <Badge variant={tokenSetVariant(set)}>{tokenSetLabel(set)}</Badge>;
+}
+
+export function formatDate(iso: string | null | undefined): string {
+  if (!iso) return '\u2014';
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return iso;
+  return d.toLocaleDateString(undefined, {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+  });
+}
+
+export function formatDateTime(iso: string | null | undefined): string {
+  if (!iso) return '\u2014';
+  const d = new Date(iso);
+  if (Number.isNaN(d.getTime())) return iso;
+  return d.toLocaleString(undefined, {
+    year: 'numeric',
+    month: 'short',
+    day: 'numeric',
+    hour: '2-digit',
+    minute: '2-digit',
+  });
+}
+
+/**
+ * Truncate a welcome message / description for table display.
+ * Keeps the first N chars and adds an ellipsis.
+ */
+export function truncate(s: string | undefined | null, n = 60): string {
+  if (!s) return '\u2014';
+  if (s.length <= n) return s;
+  return s.slice(0, n - 1) + '\u2026';
+}

+ 276 - 0
web/src/features/telegram/list.tsx

@@ -0,0 +1,276 @@
+/**
+ * List view for /v1/tenants/{id}/telegram/bots.
+ *
+ * Layout:
+ *   - For super_admin: a tenant picker (dropdown) at the top so
+ *     they can switch between companies.
+ *   - tenant_admin is not yet supported (canManageTelegram is
+ *     super_admin only in W3); we still surface a friendly
+ *     empty state.
+ *   - Below: search + status filter + the table.
+ *
+ * tenant-admin scope is enforced server-side (RequireRole
+ * "super_admin" on every endpoint).
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
+import { Search, Send } from 'lucide-react';
+
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { EmptyState } from '@/components/ui/empty-state';
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table';
+
+import { useAuth } from '@/lib/auth-context';
+import { canManageTelegram, isSuperAdmin } from '@/lib/scope';
+import { useTenantsList } from '@/features/companies/api';
+import { useTelegramBotsList } from './api';
+import { CreateTelegramBotDialog } from './create-dialog';
+import {
+  StatusBadge,
+  TokenSetBadge,
+  formatDate,
+  formatDateTime,
+  truncate,
+} from './format';
+import type { TelegramBotStatus } from './types';
+
+const STATUS_OPTIONS = [
+  { value: '', label: 'All' },
+  { value: 'active', label: 'Active' },
+  { value: 'paused', label: 'Paused' },
+];
+
+export function TelegramBotsList() {
+  const navigate = useNavigate();
+  const params = useParams();
+  const { user } = useAuth();
+  const canCreate = canManageTelegram(user?.role);
+  const isAdmin = isSuperAdmin(user?.role);
+
+  const tenantId = params.id;
+
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [q, setQ] = useState(searchParams.get('q') ?? '');
+  const status = searchParams.get('status') ?? '';
+
+  // For super_admin without a tenant in the URL, list tenants to
+  // populate the picker. We also auto-pick the first one so the
+  // rest of the page can render immediately.
+  const tenantsQ = useTenantsList({ limit: 200 });
+  useEffect(() => {
+    if (tenantId || !isAdmin) return;
+    const first = tenantsQ.data && tenantsQ.data.items[0];
+    if (first) navigate(`/telegram/${first.id}`, { replace: true });
+  }, [tenantId, isAdmin, tenantsQ.data, navigate]);
+
+  // Debounce q -> URL (300ms).
+  useEffect(() => {
+    const handle = setTimeout(() => {
+      const next = new URLSearchParams(searchParams);
+      if (q.trim()) next.set('q', q.trim());
+      else next.delete('q');
+      setSearchParams(next, { replace: true });
+    }, 300);
+    return () => clearTimeout(handle);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [q]);
+
+  const queryParams = useMemo(
+    () => ({
+      q: searchParams.get('q') ?? undefined,
+      status: status || undefined,
+      limit: 100,
+    }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [searchParams, status],
+  );
+  const { data, isPending, isError, error, refetch } = useTelegramBotsList(
+    tenantId,
+    queryParams,
+  );
+
+  const onTenantChange = (next: string) => {
+    if (next) navigate(`/telegram/${next}`);
+    else navigate('/telegram');
+  };
+
+  return (
+    <div className="flex flex-col gap-4">
+      <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-semibold tracking-tight">Telegram bots</h1>
+          <p className="text-sm text-muted-foreground">
+            Per-company Telegram bot configuration. Each bot has its own
+            token, welcome message, and default source. The token is
+            write-only — the server never returns it after save.
+          </p>
+        </div>
+        <div className="flex items-center gap-2">
+          {isAdmin && tenantsQ.data ? (
+            <select
+              className="h-9 rounded-md border border-input bg-background px-3 text-sm"
+              value={tenantId ?? ''}
+              onChange={(e) => onTenantChange(e.target.value)}
+              aria-label="Company"
+            >
+              <option value="">Select a company</option>
+              {tenantsQ.data.items.map((t) => (
+                <option key={t.id} value={t.id}>
+                  {t.display_name}
+                </option>
+              ))}
+            </select>
+          ) : null}
+          {canCreate && tenantId ? <CreateTelegramBotDialog tenantId={tenantId} /> : null}
+        </div>
+      </div>
+
+      {tenantId ? (
+        <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
+          <div className="relative flex-1 sm:max-w-sm">
+            <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+            <Input
+              placeholder="Search by id or name"
+              className="pl-8"
+              value={q}
+              onChange={(e) => setQ(e.target.value)}
+              aria-label="Search bots"
+            />
+          </div>
+          <div className="flex gap-1">
+            {STATUS_OPTIONS.map((opt) => {
+              const active = status === opt.value;
+              return (
+                <Button
+                  key={opt.value}
+                  variant={active ? 'secondary' : 'ghost'}
+                  size="sm"
+                  onClick={() => {
+                    const next = new URLSearchParams(searchParams);
+                    if (opt.value) next.set('status', opt.value);
+                    else next.delete('status');
+                    setSearchParams(next, { replace: true });
+                  }}
+                >
+                  {opt.label}
+                </Button>
+              );
+            })}
+          </div>
+        </div>
+      ) : null}
+
+      {!tenantId ? (
+        <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+          {isAdmin
+            ? 'Select a company above to see its telegram bots.'
+            : 'Telegram bots are managed by super admins. Ask your operator to set up a bot for your company.'}
+        </div>
+      ) : !isAdmin ? (
+        <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+          Only super admins can manage telegram bots in this version. W3 ships
+          super_admin scope; per-company tenant_admin is a v1.1 follow-up.
+        </div>
+      ) : isError ? (
+        <div className="rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
+          <p className="font-medium">Failed to load telegram bots.</p>
+          <p className="mt-1 text-xs opacity-80">
+            {error instanceof Error ? error.message : 'Unknown error'}
+          </p>
+          <Button variant="outline" size="sm" className="mt-2" onClick={() => refetch()}>
+            Retry
+          </Button>
+        </div>
+      ) : isPending ? (
+        <div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
+          Loading…
+        </div>
+      ) : data && data.items.length === 0 ? (
+        <EmptyState
+          icon={<Send className="h-8 w-8" />}
+          title="No bots yet"
+          description={
+            canCreate
+              ? 'Create the first telegram bot for this company.'
+              : 'No telegram bots configured for this company.'
+          }
+          action={canCreate ? <CreateTelegramBotDialog tenantId={tenantId} /> : null}
+        />
+      ) : data ? (
+        <>
+          <div className="rounded-md border bg-card">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>Name</TableHead>
+                  <TableHead>ID</TableHead>
+                  <TableHead>Status</TableHead>
+                  <TableHead>Token</TableHead>
+                  <TableHead>Default source</TableHead>
+                  <TableHead>Welcome</TableHead>
+                  <TableHead>Last rotated</TableHead>
+                  <TableHead>Created</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {data.items.map((b) => (
+                  <TableRow key={`${b.company_id}:${b.id}`}>
+                    <TableCell>
+                      <Link
+                        to={`/telegram/${tenantId}/${b.id}`}
+                        className="font-medium text-foreground hover:underline"
+                      >
+                        {b.name}
+                      </Link>
+                    </TableCell>
+                    <TableCell>
+                      <code className="rounded bg-muted px-1.5 py-0.5 text-xs">
+                        {b.id}
+                      </code>
+                    </TableCell>
+                    <TableCell>
+                      <StatusBadge status={b.status as TelegramBotStatus} />
+                    </TableCell>
+                    <TableCell>
+                      <TokenSetBadge set={b.bot_token_set} />
+                    </TableCell>
+                    <TableCell className="text-xs text-muted-foreground">
+                      {b.default_source_id ? (
+                        <code className="rounded bg-muted px-1.5 py-0.5 text-xs">
+                          {b.default_source_id}
+                        </code>
+                      ) : (
+                        '\u2014'
+                      )}
+                    </TableCell>
+                    <TableCell className="max-w-[14rem] text-xs text-muted-foreground">
+                      {truncate(b.welcome_message, 40)}
+                    </TableCell>
+                    <TableCell className="text-xs text-muted-foreground">
+                      {formatDateTime(b.last_rotated_at)}
+                    </TableCell>
+                    <TableCell className="text-xs text-muted-foreground">
+                      {formatDate(b.created_at)}
+                    </TableCell>
+                  </TableRow>
+                ))}
+              </TableBody>
+            </Table>
+          </div>
+          <p className="text-xs text-muted-foreground">
+            Showing {data.items.length} of {data.total}
+          </p>
+        </>
+      ) : null}
+    </div>
+  );
+}

+ 65 - 0
web/src/features/telegram/types.ts

@@ -0,0 +1,65 @@
+/**
+ * Wire types for the /v1/tenants/{id}/telegram/bots/* endpoints.
+ * Mirrors the Go authd.TelegramBot struct in
+ * internal/authd/telegrambots.go.
+ *
+ * Notes on the bot_token contract:
+ *   - The wire shape NEVER includes the plaintext bot_token.
+ *     It only carries the boolean `bot_token_set` that tells
+ *     the UI whether the operator has configured a token.
+ *   - The plaintext is set via POST .../telegram/bots (create)
+ *     or POST .../telegram/bots/{bid}/rotate-token (rotate).
+ *     The server stores it (so telegramd can use it) and
+ *     bcrypt-hashes it for the `bot_token_hash` column. The
+ *     server does NOT echo the plaintext back; the operator
+ *     already typed it.
+ *   - The M11 security milestone will replace the plaintext
+ *     column entirely with AES-256-GCM encryption and add a
+ *     sidecar so telegramd can decrypt.
+ */
+
+export type TelegramBotStatus = 'active' | 'paused';
+
+export interface TelegramBot {
+  id: string;
+  company_id: string;
+  name: string;
+  welcome_message?: string;
+  default_source_id?: string;
+  description?: string;
+  status: TelegramBotStatus;
+  bot_token_set: boolean;
+  last_seen_at?: string; // RFC3339
+  last_rotated_at?: string; // RFC3339
+  created_at: string; // RFC3339
+  updated_at: string; // RFC3339
+}
+
+export interface ListTelegramBotsResponse {
+  items: TelegramBot[];
+  total: number;
+  limit: number;
+  offset: number;
+}
+
+export interface CreateTelegramBotInput {
+  id: string;
+  name: string;
+  /** Plaintext. Server stores + hashes; never echoed back. */
+  bot_token: string;
+  welcome_message?: string;
+  default_source_id?: string;
+  description?: string;
+}
+
+export interface UpdateTelegramBotInput {
+  name?: string;
+  welcome_message?: string;
+  default_source_id?: string;
+  description?: string;
+}
+
+export interface RotateTelegramBotTokenInput {
+  /** Plaintext. Server stores + hashes; never echoed back. */
+  bot_token: string;
+}

+ 21 - 12
web/src/routes/telegram.tsx

@@ -1,17 +1,26 @@
-import { ComingSoon } from '@/components/ui/coming-soon';
+/**
+ * Telegram bots route. Delegates to:
+ *   /telegram             \u2192 TelegramBotsList  (super_admin sees the tenant picker)
+ *   /telegram/:id         \u2192 TelegramBotsList  (filtered to that tenant)
+ *   /telegram/:id/:bid    \u2192 TelegramBotDetailPage
+ *
+ * Sub-routes are nested so the sidebar Telegram link keeps its
+ * active state on both the list and the detail page.
+ *
+ * M13b W3 ships super_admin-only scope. W4 (or v1.1) can
+ * loosen to per-tenant tenant_admin.
+ */
+
+import { Route, Routes } from 'react-router-dom';
+import { TelegramBotsList } from '@/features/telegram/list';
+import { TelegramBotDetailPage } from '@/features/telegram/detail-page';
 
 export function TelegramRoute() {
   return (
-    <ComingSoon
-      title="Telegram bots"
-      badge="M13b W3"
-      description="Per-company bot config, invite codes, bindings table. Bot token is never displayed after save."
-    >
-      <ul className="ml-5 list-disc space-y-1 text-sm text-muted-foreground">
-        <li>Set / rotate bot token (encrypted at rest in admind)</li>
-        <li>Generate invite codes with magic link</li>
-        <li>List bindings: chat_id → individual_id mapping</li>
-      </ul>
-    </ComingSoon>
+    <Routes>
+      <Route index element={<TelegramBotsList />} />
+      <Route path=":id" element={<TelegramBotsList />} />
+      <Route path=":id/:bid" element={<TelegramBotDetailPage />} />
+    </Routes>
   );
 }

+ 97 - 0
web/tests/telegram/format.test.ts

@@ -0,0 +1,97 @@
+/**
+ * Pure-function tests for the Telegram bots formatters.
+ * No React, no network.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+  formatDate,
+  formatDateTime,
+  statusLabel,
+  statusVariant,
+  tokenSetLabel,
+  tokenSetVariant,
+  truncate,
+} from '@/features/telegram/format';
+import type { TelegramBotStatus } from '@/features/telegram/types';
+
+describe('statusLabel', () => {
+  it('humanizes the status values', () => {
+    expect(statusLabel('active')).toBe('Active');
+    expect(statusLabel('paused')).toBe('Paused');
+  });
+  it('returns the input when unknown', () => {
+    expect(statusLabel('weird' as TelegramBotStatus)).toBe('weird');
+  });
+});
+
+describe('statusVariant', () => {
+  it('maps active to success and paused to warning', () => {
+    expect(statusVariant('active')).toBe('success');
+    expect(statusVariant('paused')).toBe('warning');
+  });
+});
+
+describe('tokenSetLabel', () => {
+  it('renders Configured when set and Not set when unset', () => {
+    expect(tokenSetLabel(true)).toBe('Configured');
+    expect(tokenSetLabel(false)).toBe('Not set');
+  });
+});
+
+describe('tokenSetVariant', () => {
+  it('maps set to success and unset to warning', () => {
+    expect(tokenSetVariant(true)).toBe('success');
+    expect(tokenSetVariant(false)).toBe('warning');
+  });
+});
+
+describe('formatDate', () => {
+  it('returns em-dash for null/undefined/empty', () => {
+    expect(formatDate(null)).toBe('\u2014');
+    expect(formatDate(undefined)).toBe('\u2014');
+    expect(formatDate('')).toBe('\u2014');
+  });
+  it('returns the original string for unparseable input', () => {
+    expect(formatDate('not-a-date')).toBe('not-a-date');
+  });
+  it('formats a valid ISO date', () => {
+    const out = formatDate('2026-06-18T12:00:00Z');
+    expect(out).not.toMatch(/Invalid/);
+    expect(out.length).toBeGreaterThan(0);
+  });
+});
+
+describe('formatDateTime', () => {
+  it('returns em-dash for null/undefined/empty', () => {
+    expect(formatDateTime(null)).toBe('\u2014');
+    expect(formatDateTime(undefined)).toBe('\u2014');
+    expect(formatDateTime('')).toBe('\u2014');
+  });
+  it('formats a valid ISO date with a time component', () => {
+    const out = formatDateTime('2026-06-18T12:34:00Z');
+    expect(out).not.toMatch(/Invalid/);
+    expect(out.length).toBeGreaterThan(0);
+  });
+});
+
+describe('truncate', () => {
+  it('returns em-dash for null/undefined/empty', () => {
+    expect(truncate(null)).toBe('\u2014');
+    expect(truncate(undefined)).toBe('\u2014');
+    expect(truncate('')).toBe('\u2014');
+  });
+  it('returns the input when shorter than the cap', () => {
+    expect(truncate('hello', 10)).toBe('hello');
+  });
+  it('truncates and adds an ellipsis when over the cap', () => {
+    const out = truncate('this is a long string that will be cut off', 10);
+    expect(out.endsWith('\u2026')).toBe(true);
+    expect(out.length).toBe(10);
+  });
+  it('uses a default cap of 60', () => {
+    const out = truncate('a'.repeat(80));
+    expect(out.length).toBe(60);
+    expect(out.endsWith('\u2026')).toBe(true);
+  });
+});

+ 1 - 1
web/tsconfig.tsbuildinfo

@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/router.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/require-auth.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/use-theme.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/coming-soon.tsx","./src/components/ui/dialog.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/table.tsx","./src/components/ui/textarea.tsx","./src/features/companies/api.ts","./src/features/companies/create-dialog.tsx","./src/features/companies/detail-page.tsx","./src/features/companies/format.tsx","./src/features/companies/list.tsx","./src/features/companies/types.ts","./src/features/sources/api.ts","./src/features/sources/create-dialog.tsx","./src/features/sources/detail-page.tsx","./src/features/sources/format.tsx","./src/features/sources/list.tsx","./src/features/sources/types.ts","./src/lib/api.ts","./src/lib/auth-context.tsx","./src/lib/auth-state.ts","./src/lib/scope.ts","./src/lib/theme.ts","./src/lib/utils.ts","./src/routes/audit.tsx","./src/routes/companies.tsx","./src/routes/dlq.tsx","./src/routes/forbidden.tsx","./src/routes/index.tsx","./src/routes/login.tsx","./src/routes/not-found.tsx","./src/routes/sources.tsx","./src/routes/tail.tsx","./src/routes/telegram.tsx","./tests/companies.test.tsx","./tests/login.test.tsx","./tests/setup.ts","./tests/sources/format.test.ts"],"version":"5.9.3"}
+{"root":["./src/main.tsx","./src/router.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/require-auth.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/use-theme.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/coming-soon.tsx","./src/components/ui/dialog.tsx","./src/components/ui/empty-state.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/table.tsx","./src/components/ui/textarea.tsx","./src/features/companies/api.ts","./src/features/companies/create-dialog.tsx","./src/features/companies/detail-page.tsx","./src/features/companies/format.tsx","./src/features/companies/list.tsx","./src/features/companies/types.ts","./src/features/sources/api.ts","./src/features/sources/create-dialog.tsx","./src/features/sources/detail-page.tsx","./src/features/sources/format.tsx","./src/features/sources/list.tsx","./src/features/sources/types.ts","./src/features/telegram/api.ts","./src/features/telegram/create-dialog.tsx","./src/features/telegram/detail-page.tsx","./src/features/telegram/format.tsx","./src/features/telegram/list.tsx","./src/features/telegram/types.ts","./src/lib/api.ts","./src/lib/auth-context.tsx","./src/lib/auth-state.ts","./src/lib/scope.ts","./src/lib/theme.ts","./src/lib/utils.ts","./src/routes/audit.tsx","./src/routes/companies.tsx","./src/routes/dlq.tsx","./src/routes/forbidden.tsx","./src/routes/index.tsx","./src/routes/login.tsx","./src/routes/not-found.tsx","./src/routes/sources.tsx","./src/routes/tail.tsx","./src/routes/telegram.tsx","./tests/companies.test.tsx","./tests/login.test.tsx","./tests/setup.ts","./tests/sources/format.test.ts","./tests/telegram/format.test.ts"],"version":"5.9.3"}

Some files were not shown because too many files changed in this diff