telegrambots.go 14 KB

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