telegrambots.go 20 KB

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