telegrambots.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. // Package authd — telegrambots.go: Telegram bot CRUD for M13b W3.
  2. //
  3. // Schema (post-migration 012):
  4. // bot_id TEXT
  5. // company_id TEXT FK -> public.companies(id)
  6. // name TEXT (human label; e.g. "Acme Ops")
  7. // bot_token TEXT (plaintext; read by telegramd; M11
  8. // security milestone will replace
  9. // this with AES-256-GCM)
  10. // bot_token_hash TEXT (bcrypt; W3-added so the UI can
  11. // render "configured" without
  12. // exposing plaintext. NULL on
  13. // pre-W3 rows until the operator
  14. // rotates once.)
  15. // status TEXT (active | paused)
  16. // last_seen_at TIMESTAMPTZ
  17. // created_at TIMESTAMPTZ
  18. // welcome_message TEXT (W3; reply to /start)
  19. // default_source_id TEXT (W3; soft FK to public.sources.id)
  20. // description TEXT (W3; free-text label)
  21. // last_rotated_at TIMESTAMPTZ (W3; set on every token write)
  22. // updated_at TIMESTAMPTZ (W3; trigger-maintained)
  23. //
  24. // Wire contract (UI):
  25. // The plaintext bot_token is NEVER returned. The response
  26. // shape includes `bot_token_set` (bool: bot_token IS NOT NULL
  27. // AND bot_token <> '') so the UI can render "Configured" /
  28. // "Not set" badges. The operator pastes a token on create
  29. // and on rotate; the server stores the plaintext (so
  30. // telegramd can use it) and bcrypt-hashes it for the hash
  31. // column. The plaintext leaves the server only via the
  32. // "rotate token" handshake, where the UI receives the new
  33. // token in the response body — once. After that, it cannot
  34. // be re-fetched.
  35. //
  36. // Threading: safe for concurrent use (pgx pool is goroutine-safe).
  37. package authd
  38. import (
  39. "context"
  40. "errors"
  41. "fmt"
  42. "strings"
  43. "time"
  44. "github.com/jackc/pgx/v5"
  45. "github.com/jackc/pgx/v5/pgconn"
  46. )
  47. // TelegramBot is the wire shape returned to handlers / JSON
  48. // callers. Mirrors public.telegram_bots but excludes the
  49. // plaintext bot_token; the UI sees only the `bot_token_set`
  50. // boolean.
  51. type TelegramBot struct {
  52. ID string `json:"id"`
  53. CompanyID string `json:"company_id"`
  54. Name string `json:"name"`
  55. WelcomeMessage string `json:"welcome_message,omitempty"`
  56. DefaultSourceID string `json:"default_source_id,omitempty"`
  57. Description string `json:"description,omitempty"`
  58. Status string `json:"status"`
  59. BotTokenSet bool `json:"bot_token_set"`
  60. LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
  61. LastRotatedAt *time.Time `json:"last_rotated_at,omitempty"`
  62. CreatedAt time.Time `json:"created_at"`
  63. UpdatedAt time.Time `json:"updated_at"`
  64. }
  65. // ErrTelegramBotNotFound is returned when (company_id, id)
  66. // doesn't exist.
  67. var ErrTelegramBotNotFound = errors.New("authd: telegram bot not found")
  68. // ErrTelegramBotIDTaken is returned when CreateTelegramBot sees
  69. // a duplicate (company_id, id) for a tenant.
  70. var ErrTelegramBotIDTaken = errors.New("authd: telegram bot id already in use")
  71. // ErrTelegramBotInvalid is returned when input validation fails.
  72. var ErrTelegramBotInvalid = errors.New("authd: telegram bot input invalid")
  73. // validTelegramBotStatuses mirrors the schema default comment
  74. // in 004. The M3 schema comment says active|paused, so we use
  75. // that.
  76. var validTelegramBotStatuses = map[string]struct{}{
  77. "active": {},
  78. "paused": {},
  79. }
  80. // TelegramBotFilter controls ListTelegramBots. Empty fields
  81. // mean "no filter". CompanyID scopes the result to a single
  82. // tenant; empty means "all tenants" — the HTTP handler always
  83. // sets this so the SQL never crosses tenants.
  84. type TelegramBotFilter struct {
  85. CompanyID string
  86. Q string // matches id OR name (ILIKE)
  87. Status string // exact match
  88. Limit int
  89. Offset int
  90. }
  91. // CreateTelegramBotInput is the validated create payload. The
  92. // bot_token is required on create (the operator got it from
  93. // @BotFather and is pasting it in). WelcomeMessage and
  94. // DefaultSourceID are optional. Description is optional.
  95. type CreateTelegramBotInput struct {
  96. ID string
  97. Name string
  98. BotToken string
  99. WelcomeMessage string
  100. DefaultSourceID string
  101. Description string
  102. }
  103. // UpdateTelegramBotInput is the PATCH payload. Pointer / non-nil
  104. // fields mean "apply this." All fields optional; an empty patch
  105. // is a no-op (returns the current row).
  106. type UpdateTelegramBotInput struct {
  107. Name *string
  108. WelcomeMessage *string
  109. DefaultSourceID *string
  110. Description *string
  111. }
  112. // Validate runs the constraints the DB enforces, but earlier
  113. // and with friendlier error messages for the UI.
  114. func (in *CreateTelegramBotInput) Validate() error {
  115. if !validTelegramBotID(in.ID) {
  116. return fmt.Errorf("%w: id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
  117. }
  118. if strings.TrimSpace(in.Name) == "" {
  119. return fmt.Errorf("%w: name is required", ErrTelegramBotInvalid)
  120. }
  121. if len(in.Name) > 200 {
  122. return fmt.Errorf("%w: name must be \u2264 200 characters", ErrTelegramBotInvalid)
  123. }
  124. if !validBotTokenFormat(in.BotToken) {
  125. return fmt.Errorf("%w: bot_token must match ^\\d+:[A-Za-z0-9_-]{35}$", ErrTelegramBotInvalid)
  126. }
  127. if len(in.WelcomeMessage) > 4096 {
  128. return fmt.Errorf("%w: welcome_message must be \u2264 4096 characters", ErrTelegramBotInvalid)
  129. }
  130. if in.DefaultSourceID != "" && !validTelegramBotID(in.DefaultSourceID) {
  131. return fmt.Errorf("%w: default_source_id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
  132. }
  133. if len(in.Description) > 500 {
  134. return fmt.Errorf("%w: description must be \u2264 500 characters", ErrTelegramBotInvalid)
  135. }
  136. return nil
  137. }
  138. // Validate is the same for Update. We don't enforce presence
  139. // of fields (PATCH can be empty), just per-field constraints.
  140. func (in *UpdateTelegramBotInput) Validate() error {
  141. if in.Name != nil {
  142. s := strings.TrimSpace(*in.Name)
  143. if s == "" {
  144. return fmt.Errorf("%w: name cannot be empty", ErrTelegramBotInvalid)
  145. }
  146. if len(s) > 200 {
  147. return fmt.Errorf("%w: name must be \u2264 200 characters", ErrTelegramBotInvalid)
  148. }
  149. }
  150. if in.WelcomeMessage != nil && len(*in.WelcomeMessage) > 4096 {
  151. return fmt.Errorf("%w: welcome_message must be \u2264 4096 characters", ErrTelegramBotInvalid)
  152. }
  153. if in.DefaultSourceID != nil && *in.DefaultSourceID != "" && !validTelegramBotID(*in.DefaultSourceID) {
  154. return fmt.Errorf("%w: default_source_id must match ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$", ErrTelegramBotInvalid)
  155. }
  156. if in.Description != nil && len(*in.Description) > 500 {
  157. return fmt.Errorf("%w: description must be \u2264 500 characters", ErrTelegramBotInvalid)
  158. }
  159. return nil
  160. }
  161. // ListTelegramBots returns the bots visible to the caller under
  162. // the given filter, plus the total count. W3 scopes by tenant:
  163. // every caller sees only the bots of the tenant whose id is
  164. // passed in the URL path. super_admin can list any tenant;
  165. // tenant_admin can list their own (gate enforced in the
  166. // HTTP handler, not here).
  167. func (s *Store) ListTelegramBots(ctx context.Context, f TelegramBotFilter) ([]TelegramBot, int, error) {
  168. if s.pool == nil {
  169. return nil, 0, errors.New("authd: no DB pool (test mode)")
  170. }
  171. if f.Limit <= 0 {
  172. f.Limit = 100
  173. }
  174. if f.Limit > 500 {
  175. f.Limit = 500
  176. }
  177. args := []any{}
  178. conds := []string{}
  179. if strings.TrimSpace(f.CompanyID) != "" {
  180. args = append(args, f.CompanyID)
  181. conds = append(conds, fmt.Sprintf("company_id = $%d", len(args)))
  182. }
  183. if strings.TrimSpace(f.Status) != "" {
  184. args = append(args, f.Status)
  185. conds = append(conds, fmt.Sprintf("status = $%d", len(args)))
  186. }
  187. if strings.TrimSpace(f.Q) != "" {
  188. args = append(args, "%"+strings.TrimSpace(f.Q)+"%")
  189. conds = append(conds, fmt.Sprintf("(bot_id ILIKE $%d OR name ILIKE $%d)", len(args), len(args)))
  190. }
  191. where := ""
  192. if len(conds) > 0 {
  193. where = "WHERE " + strings.Join(conds, " AND ")
  194. }
  195. var total int
  196. if err := s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM public.telegram_bots "+where, args...).Scan(&total); err != nil {
  197. return nil, 0, fmt.Errorf("count telegram_bots: %w", err)
  198. }
  199. args = append(args, f.Limit, f.Offset)
  200. q := fmt.Sprintf(`
  201. SELECT bot_id, company_id, name,
  202. COALESCE(welcome_message, ''),
  203. COALESCE(default_source_id, ''),
  204. COALESCE(description, ''),
  205. status,
  206. (bot_token IS NOT NULL AND bot_token <> ''),
  207. last_seen_at, last_rotated_at, created_at, updated_at
  208. FROM public.telegram_bots
  209. %s
  210. ORDER BY created_at DESC
  211. LIMIT $%d OFFSET $%d
  212. `, where, len(args)-1, len(args))
  213. rows, err := s.pool.Query(ctx, q, args...)
  214. if err != nil {
  215. return nil, 0, fmt.Errorf("list telegram_bots: %w", err)
  216. }
  217. defer rows.Close()
  218. out := make([]TelegramBot, 0, f.Limit)
  219. for rows.Next() {
  220. var b TelegramBot
  221. if err := rows.Scan(
  222. &b.ID, &b.CompanyID, &b.Name,
  223. &b.WelcomeMessage, &b.DefaultSourceID, &b.Description,
  224. &b.Status, &b.BotTokenSet,
  225. &b.LastSeenAt, &b.LastRotatedAt, &b.CreatedAt, &b.UpdatedAt,
  226. ); err != nil {
  227. return nil, 0, fmt.Errorf("scan telegram_bot: %w", err)
  228. }
  229. out = append(out, b)
  230. }
  231. if err := rows.Err(); err != nil {
  232. return nil, 0, fmt.Errorf("rows: %w", err)
  233. }
  234. return out, total, nil
  235. }
  236. // GetTelegramBot fetches a single bot by (company_id, id).
  237. // Returns ErrTelegramBotNotFound if missing. The handler is
  238. // responsible for the per-id scope check; this method is a
  239. // straight DB lookup.
  240. func (s *Store) GetTelegramBot(ctx context.Context, companyID, botID string) (*TelegramBot, error) {
  241. if s.pool == nil {
  242. return nil, errors.New("authd: no DB pool (test mode)")
  243. }
  244. const q = `
  245. SELECT bot_id, company_id, name,
  246. COALESCE(welcome_message, ''),
  247. COALESCE(default_source_id, ''),
  248. COALESCE(description, ''),
  249. status,
  250. (bot_token IS NOT NULL AND bot_token <> ''),
  251. last_seen_at, last_rotated_at, created_at, updated_at
  252. FROM public.telegram_bots
  253. WHERE company_id = $1 AND bot_id = $2
  254. `
  255. bot := &TelegramBot{}
  256. err := s.pool.QueryRow(ctx, q, companyID, botID).Scan(
  257. &bot.ID, &bot.CompanyID, &bot.Name,
  258. &bot.WelcomeMessage, &bot.DefaultSourceID, &bot.Description,
  259. &bot.Status, &bot.BotTokenSet,
  260. &bot.LastSeenAt, &bot.LastRotatedAt, &bot.CreatedAt, &bot.UpdatedAt,
  261. )
  262. if err != nil {
  263. if errors.Is(err, pgx.ErrNoRows) {
  264. return nil, ErrTelegramBotNotFound
  265. }
  266. return nil, fmt.Errorf("get telegram_bot: %w", err)
  267. }
  268. return bot, nil
  269. }
  270. // CreateTelegramBot inserts a new bot and writes audit. The
  271. // bot_token is stored in plaintext (telegramd reads it) AND
  272. // bcrypt-hashed (so the UI can render "configured" without
  273. // exposing the plaintext). Returns the wire-shape row, which
  274. // includes bot_token_set=true. The plaintext is NOT returned
  275. // in the response (the operator just typed it in; no need to
  276. // echo it).
  277. //
  278. // Behavior:
  279. // - Duplicate (company_id, id) → ErrTelegramBotIDTaken (409).
  280. // - last_rotated_at is set to now() because the token was
  281. // just written. updated_at is set by the trigger.
  282. func (s *Store) CreateTelegramBot(
  283. ctx context.Context,
  284. companyID, tenantDisplayName string,
  285. in CreateTelegramBotInput,
  286. actorUserID, actorIP, actorUA string,
  287. ) (*TelegramBot, error) {
  288. if s.pool == nil {
  289. return nil, errors.New("authd: no DB pool (test mode)")
  290. }
  291. if err := in.Validate(); err != nil {
  292. return nil, err
  293. }
  294. // Bridge: telegram_bots.company_id is a TEXT FK to
  295. // public.companies(id). M13a created auth.tenants; the
  296. // legacy public.companies row is what telegram_bots
  297. // references. ensurePublicCompanyRow (defined in
  298. // sources.go) is the same idempotent INSERT … ON CONFLICT
  299. // DO NOTHING we use for source create, so we don't 500
  300. // when a tenant has no companies row yet.
  301. if err := s.ensurePublicCompanyRow(ctx, companyID, tenantDisplayName); err != nil {
  302. return nil, err
  303. }
  304. hash, err := hashBotToken(in.BotToken)
  305. if err != nil {
  306. return nil, err
  307. }
  308. now := time.Now().UTC()
  309. const q = `
  310. INSERT INTO public.telegram_bots
  311. (bot_id, company_id, name, bot_token, bot_token_hash,
  312. welcome_message, default_source_id, description,
  313. status, last_rotated_at)
  314. VALUES
  315. ($1, $2::text, $3, $4, $5,
  316. NULLIF($6, ''), NULLIF($7, ''), NULLIF($8, ''),
  317. 'active', $9)
  318. RETURNING bot_id, company_id, name,
  319. COALESCE(welcome_message, ''),
  320. COALESCE(default_source_id, ''),
  321. COALESCE(description, ''),
  322. status,
  323. (bot_token IS NOT NULL AND bot_token <> ''),
  324. last_seen_at, last_rotated_at, created_at, updated_at
  325. `
  326. bot := &TelegramBot{}
  327. err = s.pool.QueryRow(ctx, q,
  328. in.ID, companyID, strings.TrimSpace(in.Name),
  329. in.BotToken, hash,
  330. in.WelcomeMessage, in.DefaultSourceID, in.Description,
  331. now,
  332. ).Scan(
  333. &bot.ID, &bot.CompanyID, &bot.Name,
  334. &bot.WelcomeMessage, &bot.DefaultSourceID, &bot.Description,
  335. &bot.Status, &bot.BotTokenSet,
  336. &bot.LastSeenAt, &bot.LastRotatedAt, &bot.CreatedAt, &bot.UpdatedAt,
  337. )
  338. if err != nil {
  339. var pgErr *pgconn.PgError
  340. if errors.As(err, &pgErr) && pgErr.Code == "23505" {
  341. return nil, ErrTelegramBotIDTaken
  342. }
  343. return nil, fmt.Errorf("create telegram_bot: %w", err)
  344. }
  345. // Audit. The plaintext token is NOT included.
  346. if err := s.WriteAudit(ctx, "telegram_bot.create", actorUserID, actorIP, actorUA, bot.CompanyID, bot.ID, map[string]any{
  347. "name": bot.Name,
  348. "default_source_id": bot.DefaultSourceID,
  349. "has_welcome_message": bot.WelcomeMessage != "",
  350. "bot_token_set": bot.BotTokenSet,
  351. }); err != nil {
  352. _ = err
  353. }
  354. return bot, nil
  355. }
  356. // UpdateTelegramBot applies a partial update and writes audit.
  357. // The bot_token is NOT updatable through this method (rotate is
  358. // a separate action with its own audit trail and its own
  359. // response shape).
  360. func (s *Store) UpdateTelegramBot(
  361. ctx context.Context,
  362. companyID, botID string,
  363. in UpdateTelegramBotInput,
  364. actorUserID, actorIP, actorUA string,
  365. ) (*TelegramBot, error) {
  366. if s.pool == nil {
  367. return nil, errors.New("authd: no DB pool (test mode)")
  368. }
  369. if err := in.Validate(); err != nil {
  370. return nil, err
  371. }
  372. sets := []string{}
  373. args := []any{companyID, botID}
  374. if in.Name != nil {
  375. args = append(args, strings.TrimSpace(*in.Name))
  376. sets = append(sets, fmt.Sprintf("name = $%d", len(args)))
  377. }
  378. if in.WelcomeMessage != nil {
  379. args = append(args, *in.WelcomeMessage)
  380. sets = append(sets, fmt.Sprintf("welcome_message = NULLIF($%d, '')", len(args)))
  381. }
  382. if in.DefaultSourceID != nil {
  383. args = append(args, *in.DefaultSourceID)
  384. sets = append(sets, fmt.Sprintf("default_source_id = NULLIF($%d, '')", len(args)))
  385. }
  386. if in.Description != nil {
  387. args = append(args, *in.Description)
  388. sets = append(sets, fmt.Sprintf("description = NULLIF($%d, '')", len(args)))
  389. }
  390. if len(sets) == 0 {
  391. return s.GetTelegramBot(ctx, companyID, botID)
  392. }
  393. q := fmt.Sprintf("UPDATE public.telegram_bots SET %s WHERE company_id = $1 AND bot_id = $2", strings.Join(sets, ", "))
  394. tag, err := s.pool.Exec(ctx, q, args...)
  395. if err != nil {
  396. return nil, fmt.Errorf("update telegram_bot: %w", err)
  397. }
  398. if tag.RowsAffected() == 0 {
  399. return nil, ErrTelegramBotNotFound
  400. }
  401. payload := map[string]any{}
  402. if in.Name != nil {
  403. payload["name"] = *in.Name
  404. }
  405. if in.WelcomeMessage != nil {
  406. payload["welcome_message_set"] = true
  407. }
  408. if in.DefaultSourceID != nil {
  409. payload["default_source_id"] = *in.DefaultSourceID
  410. }
  411. if in.Description != nil {
  412. payload["description_set"] = true
  413. }
  414. if err := s.WriteAudit(ctx, "telegram_bot.update", actorUserID, actorIP, actorUA, companyID, botID, payload); err != nil {
  415. _ = err
  416. }
  417. return s.GetTelegramBot(ctx, companyID, botID)
  418. }
  419. // SetTelegramBotStatus flips status. Allowed transitions:
  420. // active -> paused
  421. // paused -> active
  422. // No "archived" / "deleted" state for bots in v1 (operators
  423. // leave them paused; archival is a v1.1 feature).
  424. func (s *Store) SetTelegramBotStatus(
  425. ctx context.Context,
  426. companyID, botID, newStatus string,
  427. actorUserID, actorIP, actorUA string,
  428. ) (*TelegramBot, error) {
  429. if s.pool == nil {
  430. return nil, errors.New("authd: no DB pool (test mode)")
  431. }
  432. if _, ok := validTelegramBotStatuses[newStatus]; !ok {
  433. return nil, fmt.Errorf("%w: status must be active|paused", ErrTelegramBotInvalid)
  434. }
  435. cur, err := s.GetTelegramBot(ctx, companyID, botID)
  436. if err != nil {
  437. return nil, err
  438. }
  439. if cur.Status == newStatus {
  440. return cur, nil
  441. }
  442. if _, err := s.pool.Exec(ctx,
  443. "UPDATE public.telegram_bots SET status = $3 WHERE company_id = $1 AND bot_id = $2",
  444. companyID, botID, newStatus); err != nil {
  445. return nil, fmt.Errorf("set telegram_bot status: %w", err)
  446. }
  447. if err := s.WriteAudit(ctx, "telegram_bot.status", actorUserID, actorIP, actorUA, companyID, botID, map[string]any{
  448. "from": cur.Status,
  449. "to": newStatus,
  450. }); err != nil {
  451. _ = err
  452. }
  453. return s.GetTelegramBot(ctx, companyID, botID)
  454. }
  455. // RotateTelegramBotToken sets a new bot_token, replacing the
  456. // existing one. The new token is bcrypt-hashed and written to
  457. // bot_token_hash; the plaintext replaces bot_token (telegramd
  458. // will pick it up on the next reload — v1.1 adds a
  459. // notification channel; W3 simply relies on the periodic poll
  460. // restart). last_rotated_at is set to now().
  461. //
  462. // Returns the updated row. The plaintext is NOT echoed back —
  463. // the operator just typed it, they already have it. If you
  464. // want the server to generate a token, use the dedicated
  465. // "create bot with BotFather" path (out of scope for v1).
  466. func (s *Store) RotateTelegramBotToken(
  467. ctx context.Context,
  468. companyID, botID, newToken string,
  469. actorUserID, actorIP, actorUA string,
  470. ) (*TelegramBot, error) {
  471. if s.pool == nil {
  472. return nil, errors.New("authd: no DB pool (test mode)")
  473. }
  474. if !validBotTokenFormat(newToken) {
  475. return nil, fmt.Errorf("%w: bot_token must match ^\\d+:[A-Za-z0-9_-]{35}$", ErrTelegramBotInvalid)
  476. }
  477. // Confirm the bot exists first; surface 404 before doing
  478. // any work.
  479. if _, err := s.GetTelegramBot(ctx, companyID, botID); err != nil {
  480. return nil, err
  481. }
  482. hash, err := hashBotToken(newToken)
  483. if err != nil {
  484. return nil, err
  485. }
  486. now := time.Now().UTC()
  487. if _, err := s.pool.Exec(ctx,
  488. "UPDATE public.telegram_bots SET bot_token = $3, bot_token_hash = $4, last_rotated_at = $5 WHERE company_id = $1 AND bot_id = $2",
  489. companyID, botID, newToken, hash, now); err != nil {
  490. return nil, fmt.Errorf("rotate telegram_bot token: %w", err)
  491. }
  492. if err := s.WriteAudit(ctx, "telegram_bot.rotate_token", actorUserID, actorIP, actorUA, companyID, botID, map[string]any{
  493. "rotated": true,
  494. }); err != nil {
  495. _ = err
  496. }
  497. return s.GetTelegramBot(ctx, companyID, botID)
  498. }
  499. // -------------------------------------------------------------------
  500. // helpers
  501. // -------------------------------------------------------------------
  502. // validTelegramBotID matches the same regex as auth.tenants.slug
  503. // and source IDs: ^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$.
  504. //
  505. // Why a separate function? The bot id appears in a public
  506. // Telegram URL (`t.me/<bot>`) and as part of the api.telegram.org
  507. // path; keeping the same charset as the rest of the system
  508. // avoids any URL-encoding gotchas.
  509. func validTelegramBotID(s string) bool {
  510. if len(s) < 2 || len(s) > 64 {
  511. return false
  512. }
  513. if !isAlnumOrDash(s[0]) || s[0] == '-' {
  514. return false
  515. }
  516. if !isAlnumOrDash(s[len(s)-1]) || s[len(s)-1] == '-' {
  517. return false
  518. }
  519. for i := 1; i < len(s)-1; i++ {
  520. if !isAlnumOrDash(s[i]) {
  521. return false
  522. }
  523. }
  524. return true
  525. }
  526. // validBotTokenFormat — Telegram bot tokens look like
  527. // <bot_id>:<secret>
  528. // where bot_id is a decimal integer (8-10 digits) and secret
  529. // is 35 [A-Za-z0-9_-] chars. The full regex Telegram documents
  530. // is `^\d+:[A-Za-z0-9_-]{35}$`; we accept the same shape.
  531. // (Real BotFather tokens are exactly 46 chars including the
  532. // colon; we use the more lenient regex from M13b_PLAN §2.3.)
  533. func validBotTokenFormat(s string) bool {
  534. if len(s) < 37 || len(s) > 100 {
  535. // minimum 1+1+35 = 37; upper bound is generous
  536. return false
  537. }
  538. colon := -1
  539. for i, c := range s {
  540. if c == ':' {
  541. if colon >= 0 {
  542. return false // more than one colon
  543. }
  544. colon = i
  545. }
  546. }
  547. if colon < 1 || colon == len(s)-1 {
  548. return false
  549. }
  550. // bot id part: digits only
  551. for i := 0; i < colon; i++ {
  552. if s[i] < '0' || s[i] > '9' {
  553. return false
  554. }
  555. }
  556. // secret part: 35+ [A-Za-z0-9_-]
  557. if len(s)-colon-1 < 35 {
  558. return false
  559. }
  560. for i := colon + 1; i < len(s); i++ {
  561. c := s[i]
  562. if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
  563. (c >= '0' && c <= '9') || c == '_' || c == '-') {
  564. return false
  565. }
  566. }
  567. return true
  568. }
  569. // hashBotToken bcrypts the plaintext token at cost 10. The
  570. // actual hash is never used to validate anything (Telegram
  571. // validates by checking the plaintext itself); the hash
  572. // column exists so the UI can render "configured" without
  573. // the server having to expose the plaintext. Cost 10 mirrors
  574. // the source hmac_secret path.
  575. func hashBotToken(plain string) (string, error) {
  576. return hashSecret(plain, "bot_token")
  577. }