telegrambots.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // telegrambots.go — HTTP handlers for the /v1/tenants/{id}/telegram/bots/*
  2. // routes (M13b W3).
  3. //
  4. // Routes (all require super_admin role per the existing
  5. // canManageTelegram scope):
  6. //
  7. // GET /v1/tenants/{id}/telegram/bots — list
  8. // POST /v1/tenants/{id}/telegram/bots — create
  9. // GET /v1/tenants/{id}/telegram/bots/{bid} — detail
  10. // PATCH /v1/tenants/{id}/telegram/bots/{bid} — update
  11. // POST /v1/tenants/{id}/telegram/bots/{bid}/status — set status
  12. // POST /v1/tenants/{id}/telegram/bots/{bid}/rotate-token — rotate token
  13. //
  14. // Errors:
  15. // 400 — bad input (validation, JSON parse, bad UUID, bad bot id)
  16. // 401 — handled by RequireAuth middleware (no body rewrite here)
  17. // 403 — caller is not super_admin (RequireRole gate)
  18. // 404 — tenant or bot id not found
  19. // 409 — duplicate bot id on create
  20. // 500 — unexpected DB error
  21. //
  22. // The plaintext bot_token is NEVER returned in any response.
  23. // The wire shape is the authd.TelegramBot struct, which exposes
  24. // `bot_token_set: bool` instead.
  25. package main
  26. import (
  27. "encoding/json"
  28. "errors"
  29. "log/slog"
  30. "net/http"
  31. "strconv"
  32. "strings"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/authd"
  34. )
  35. // telegramBotsListResponse is the wire shape for GET
  36. // /v1/tenants/{id}/telegram/bots.
  37. type telegramBotsListResponse struct {
  38. Items []authd.TelegramBot `json:"items"`
  39. Total int `json:"total"`
  40. Limit int `json:"limit"`
  41. Offset int `json:"offset"`
  42. }
  43. // listTelegramBotsHandler wires GET /v1/tenants/{id}/telegram/bots.
  44. func listTelegramBotsHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  45. return func(w http.ResponseWriter, r *http.Request) {
  46. claims := authd.ClaimsFromContext(r.Context())
  47. if claims == nil {
  48. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  49. return
  50. }
  51. tenantID := r.PathValue("id")
  52. if !isUUID(tenantID) {
  53. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  54. return
  55. }
  56. q := strings.TrimSpace(r.URL.Query().Get("q"))
  57. statusFilter := strings.TrimSpace(r.URL.Query().Get("status"))
  58. limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
  59. offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
  60. filter := authd.TelegramBotFilter{
  61. Q: q,
  62. Status: statusFilter,
  63. Limit: limit,
  64. Offset: offset,
  65. }
  66. items, total, err := ad.Store().ListTelegramBots(r.Context(), filter)
  67. if err != nil {
  68. logger.Error("list telegram_bots", "err", err, "actor", claims.UserID, "tenant_id", tenantID)
  69. writeErr(w, http.StatusInternalServerError, "internal", "list failed")
  70. return
  71. }
  72. if filter.Limit <= 0 {
  73. filter.Limit = 100
  74. }
  75. if filter.Limit > 500 {
  76. filter.Limit = 500
  77. }
  78. writeJSON(w, http.StatusOK, telegramBotsListResponse{
  79. Items: items, Total: total, Limit: filter.Limit, Offset: filter.Offset,
  80. })
  81. }
  82. }
  83. // createTelegramBotRequest is the POST body. bot_token is
  84. // REQUIRED on create (the operator got it from @BotFather).
  85. // All other fields optional.
  86. type createTelegramBotRequest struct {
  87. ID string `json:"id"`
  88. Name string `json:"name"`
  89. BotToken string `json:"bot_token"`
  90. WelcomeMessage string `json:"welcome_message"`
  91. DefaultSourceID string `json:"default_source_id"`
  92. Description string `json:"description"`
  93. }
  94. // createTelegramBotHandler wires POST /v1/tenants/{id}/telegram/bots.
  95. func createTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  96. return func(w http.ResponseWriter, r *http.Request) {
  97. claims := authd.ClaimsFromContext(r.Context())
  98. if claims == nil {
  99. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  100. return
  101. }
  102. tenantID := r.PathValue("id")
  103. if !isUUID(tenantID) {
  104. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  105. return
  106. }
  107. // W3 gates telegram to super_admin only (see canManageTelegram
  108. // in web/src/lib/scope.ts). RequireRole is wired in main.go;
  109. // this is belt-and-suspenders.
  110. if claims.Role != "super_admin" {
  111. writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
  112. return
  113. }
  114. var req createTelegramBotRequest
  115. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  116. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  117. return
  118. }
  119. in := authd.CreateTelegramBotInput{
  120. ID: strings.TrimSpace(req.ID),
  121. Name: strings.TrimSpace(req.Name),
  122. BotToken: strings.TrimSpace(req.BotToken),
  123. WelcomeMessage: req.WelcomeMessage,
  124. DefaultSourceID: strings.TrimSpace(req.DefaultSourceID),
  125. Description: req.Description,
  126. }
  127. // Look up the auth tenant's display name so the
  128. // bridge INSERT into public.companies (needed because
  129. // telegram_bots.company_id FKs into public.companies,
  130. // not auth.tenants) has a sensible name value.
  131. tenant, err := ad.Store().GetTenant(r.Context(), tenantID)
  132. if err != nil {
  133. if errors.Is(err, authd.ErrTenantNotFound) {
  134. writeErr(w, http.StatusNotFound, "not_found", "tenant not found")
  135. return
  136. }
  137. logger.Error("create telegram_bot: lookup tenant", "err", err, "tenant_id", tenantID)
  138. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  139. return
  140. }
  141. ip := clientIP(r)
  142. ua := r.UserAgent()
  143. bot, err := ad.Store().CreateTelegramBot(r.Context(), tenantID, tenant.DisplayName, in, claims.UserID, ip, ua)
  144. if err != nil {
  145. switch {
  146. case errors.Is(err, authd.ErrTelegramBotIDTaken):
  147. writeErr(w, http.StatusConflict, "id_taken", "telegram bot id already in use")
  148. case errors.Is(err, authd.ErrTelegramBotInvalid):
  149. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  150. default:
  151. logger.Error("create telegram_bot", "err", err, "tenant_id", tenantID, "actor", claims.UserID)
  152. writeErr(w, http.StatusInternalServerError, "internal", "create failed")
  153. }
  154. return
  155. }
  156. logger.Info("telegram_bot created",
  157. "tenant_id", tenantID, "bot_id", bot.ID, "actor", claims.UserID)
  158. writeJSON(w, http.StatusCreated, bot)
  159. }
  160. }
  161. // getTelegramBotHandler wires GET /v1/tenants/{id}/telegram/bots/{bid}.
  162. func getTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  163. return func(w http.ResponseWriter, r *http.Request) {
  164. claims := authd.ClaimsFromContext(r.Context())
  165. if claims == nil {
  166. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  167. return
  168. }
  169. tenantID := r.PathValue("id")
  170. if !isUUID(tenantID) {
  171. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  172. return
  173. }
  174. if claims.Role != "super_admin" {
  175. writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
  176. return
  177. }
  178. botID := r.PathValue("bid")
  179. if botID == "" {
  180. writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
  181. return
  182. }
  183. bot, err := ad.Store().GetTelegramBot(r.Context(), tenantID, botID)
  184. if err != nil {
  185. if errors.Is(err, authd.ErrTelegramBotNotFound) {
  186. writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
  187. return
  188. }
  189. logger.Error("get telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID)
  190. writeErr(w, http.StatusInternalServerError, "internal", "lookup failed")
  191. return
  192. }
  193. writeJSON(w, http.StatusOK, bot)
  194. }
  195. }
  196. // updateTelegramBotRequest is the PATCH body. bot_token is
  197. // NOT updatable here — use POST .../rotate-token.
  198. type updateTelegramBotRequest struct {
  199. Name *string `json:"name"`
  200. WelcomeMessage *string `json:"welcome_message"`
  201. DefaultSourceID *string `json:"default_source_id"`
  202. Description *string `json:"description"`
  203. }
  204. // updateTelegramBotHandler wires PATCH /v1/tenants/{id}/telegram/bots/{bid}.
  205. func updateTelegramBotHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  206. return func(w http.ResponseWriter, r *http.Request) {
  207. claims := authd.ClaimsFromContext(r.Context())
  208. if claims == nil {
  209. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  210. return
  211. }
  212. tenantID := r.PathValue("id")
  213. if !isUUID(tenantID) {
  214. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  215. return
  216. }
  217. if claims.Role != "super_admin" {
  218. writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
  219. return
  220. }
  221. botID := r.PathValue("bid")
  222. if botID == "" {
  223. writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
  224. return
  225. }
  226. var req updateTelegramBotRequest
  227. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  228. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  229. return
  230. }
  231. in := authd.UpdateTelegramBotInput{
  232. Name: req.Name,
  233. WelcomeMessage: req.WelcomeMessage,
  234. DefaultSourceID: req.DefaultSourceID,
  235. Description: req.Description,
  236. }
  237. ip := clientIP(r)
  238. ua := r.UserAgent()
  239. bot, err := ad.Store().UpdateTelegramBot(r.Context(), tenantID, botID, in, claims.UserID, ip, ua)
  240. if err != nil {
  241. switch {
  242. case errors.Is(err, authd.ErrTelegramBotNotFound):
  243. writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
  244. case errors.Is(err, authd.ErrTelegramBotInvalid):
  245. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  246. default:
  247. logger.Error("update telegram_bot", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
  248. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  249. }
  250. return
  251. }
  252. logger.Info("telegram_bot updated",
  253. "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
  254. writeJSON(w, http.StatusOK, bot)
  255. }
  256. }
  257. // setTelegramBotStatusRequest is the POST /status body.
  258. type setTelegramBotStatusRequest struct {
  259. Status string `json:"status"`
  260. }
  261. // setTelegramBotStatusHandler wires POST /v1/tenants/{id}/telegram/bots/{bid}/status.
  262. func setTelegramBotStatusHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  263. return func(w http.ResponseWriter, r *http.Request) {
  264. claims := authd.ClaimsFromContext(r.Context())
  265. if claims == nil {
  266. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  267. return
  268. }
  269. tenantID := r.PathValue("id")
  270. if !isUUID(tenantID) {
  271. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  272. return
  273. }
  274. if claims.Role != "super_admin" {
  275. writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
  276. return
  277. }
  278. botID := r.PathValue("bid")
  279. if botID == "" {
  280. writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
  281. return
  282. }
  283. var req setTelegramBotStatusRequest
  284. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  285. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  286. return
  287. }
  288. ip := clientIP(r)
  289. ua := r.UserAgent()
  290. bot, err := ad.Store().SetTelegramBotStatus(r.Context(), tenantID, botID, strings.TrimSpace(req.Status), claims.UserID, ip, ua)
  291. if err != nil {
  292. switch {
  293. case errors.Is(err, authd.ErrTelegramBotNotFound):
  294. writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
  295. case errors.Is(err, authd.ErrTelegramBotInvalid):
  296. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  297. default:
  298. logger.Error("set telegram_bot status", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
  299. writeErr(w, http.StatusInternalServerError, "internal", "update failed")
  300. }
  301. return
  302. }
  303. logger.Info("telegram_bot status changed",
  304. "tenant_id", tenantID, "bot_id", botID, "to", bot.Status, "actor", claims.UserID)
  305. writeJSON(w, http.StatusOK, bot)
  306. }
  307. }
  308. // rotateTelegramBotTokenRequest is the POST /rotate-token body.
  309. // bot_token is REQUIRED (the operator got a new one from
  310. // @BotFather and is pasting it in).
  311. type rotateTelegramBotTokenRequest struct {
  312. BotToken string `json:"bot_token"`
  313. }
  314. // rotateTelegramBotTokenHandler wires POST
  315. // /v1/tenants/{id}/telegram/bots/{bid}/rotate-token.
  316. //
  317. // The new bot_token replaces the existing one in the DB and is
  318. // bcrypt-hashed for the bot_token_hash column. The response
  319. // does NOT include the plaintext (the operator already has it;
  320. // the server doesn't echo it back).
  321. func rotateTelegramBotTokenHandler(ad *authd.Authd, logger *slog.Logger) http.HandlerFunc {
  322. return func(w http.ResponseWriter, r *http.Request) {
  323. claims := authd.ClaimsFromContext(r.Context())
  324. if claims == nil {
  325. writeErr(w, http.StatusUnauthorized, "unauthorized", "claims missing")
  326. return
  327. }
  328. tenantID := r.PathValue("id")
  329. if !isUUID(tenantID) {
  330. writeErr(w, http.StatusBadRequest, "bad_request", "tenant id must be a UUID")
  331. return
  332. }
  333. if claims.Role != "super_admin" {
  334. writeErr(w, http.StatusForbidden, "forbidden", "super_admin only")
  335. return
  336. }
  337. botID := r.PathValue("bid")
  338. if botID == "" {
  339. writeErr(w, http.StatusBadRequest, "bad_request", "bot id is required")
  340. return
  341. }
  342. var req rotateTelegramBotTokenRequest
  343. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
  344. writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
  345. return
  346. }
  347. ip := clientIP(r)
  348. ua := r.UserAgent()
  349. bot, err := ad.Store().RotateTelegramBotToken(r.Context(), tenantID, botID, strings.TrimSpace(req.BotToken), claims.UserID, ip, ua)
  350. if err != nil {
  351. switch {
  352. case errors.Is(err, authd.ErrTelegramBotNotFound):
  353. writeErr(w, http.StatusNotFound, "not_found", "telegram bot not found")
  354. case errors.Is(err, authd.ErrTelegramBotInvalid):
  355. writeErr(w, http.StatusBadRequest, "invalid", err.Error())
  356. default:
  357. logger.Error("rotate telegram_bot token", "err", err, "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
  358. writeErr(w, http.StatusInternalServerError, "internal", "rotate failed")
  359. }
  360. return
  361. }
  362. logger.Info("telegram_bot token rotated",
  363. "tenant_id", tenantID, "bot_id", botID, "actor", claims.UserID)
  364. writeJSON(w, http.StatusOK, bot)
  365. }
  366. }