client.go 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. // Package telegram is the Bot API client and command handler
  2. // for the broad-announce M3 milestone (SPEC §8).
  3. //
  4. // The package is split into three files:
  5. //
  6. // - client.go — BotClient interface + HTTP impl + fakes
  7. // - commands.go — text → Command parser
  8. // - handler.go — Command → DB updates
  9. //
  10. // M3 uses long-polling (cmd/telegramd). Webhook mode is M5/M9.
  11. //
  12. // All bot tokens, chat IDs, and user IDs are in the bot's
  13. // per-company namespace; the client takes a bot token as
  14. // input on every call so the same client can be reused
  15. // across bots in a multi-bot future.
  16. package telegram
  17. import (
  18. "bytes"
  19. "context"
  20. "encoding/json"
  21. "fmt"
  22. "io"
  23. "net/http"
  24. "time"
  25. )
  26. // Update is the subset of the Telegram Update object we care
  27. // about. We only need the message (incoming text) and the
  28. // update_id (offset for long-polling).
  29. type Update struct {
  30. UpdateID int64 `json:"update_id"`
  31. Message *Message `json:"message,omitempty"`
  32. }
  33. // Message is the subset of Message we care about.
  34. type Message struct {
  35. MessageID int64 `json:"message_id"`
  36. From *User `json:"from,omitempty"`
  37. Chat Chat `json:"chat"`
  38. Text string `json:"text,omitempty"`
  39. Date int64 `json:"date,omitempty"`
  40. }
  41. // User is the subset of User we care about.
  42. type User struct {
  43. ID int64 `json:"id"`
  44. IsBot bool `json:"is_bot"`
  45. FirstName string `json:"first_name"`
  46. Username string `json:"username,omitempty"`
  47. }
  48. // Chat is the subset of Chat we care about.
  49. type Chat struct {
  50. ID int64 `json:"id"`
  51. Type string `json:"type"` // private, group, supergroup, channel
  52. }
  53. // SentMessage is the API response from sendMessage.
  54. type SentMessage struct {
  55. MessageID int64 `json:"message_id"`
  56. Chat Chat `json:"chat"`
  57. Date int64 `json:"date"`
  58. Text string `json:"text"`
  59. }
  60. // BotClient is the minimal interface deliverd-telegram and
  61. // telegramd need. It can be swapped for a fake in tests
  62. // (faketgmd is a fake SERVER; this is the client-side
  63. // interface for swapping in process-local fakes).
  64. type BotClient interface {
  65. SendMessage(ctx context.Context, token string, chatID int64, text string) (*SentMessage, error)
  66. GetUpdates(ctx context.Context, token string, offset int64, timeoutSec int) ([]Update, error)
  67. }
  68. // HTTPBotClient is the real-HTTP implementation, hitting
  69. // https://api.telegram.org/bot<token>/...
  70. type HTTPBotClient struct {
  71. BaseURL string // override for tests; default https://api.telegram.org
  72. HTTP *http.Client // override for tests
  73. }
  74. // NewHTTPBotClient returns a client with sensible defaults.
  75. func NewHTTPBotClient() *HTTPBotClient {
  76. return &HTTPBotClient{
  77. BaseURL: "https://api.telegram.org",
  78. HTTP: &http.Client{Timeout: 60 * time.Second},
  79. }
  80. }
  81. // SendMessage posts a text message to a chat.
  82. func (c *HTTPBotClient) SendMessage(ctx context.Context, token string, chatID int64, text string) (*SentMessage, error) {
  83. url := fmt.Sprintf("%s/bot%s/sendMessage", c.BaseURL, token)
  84. body, _ := json.Marshal(map[string]any{
  85. "chat_id": chatID,
  86. "text": text,
  87. })
  88. req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
  89. if err != nil {
  90. return nil, err
  91. }
  92. req.Header.Set("Content-Type", "application/json")
  93. resp, err := c.HTTP.Do(req)
  94. if err != nil {
  95. return nil, err
  96. }
  97. defer resp.Body.Close()
  98. if resp.StatusCode/100 != 2 {
  99. respBody, _ := io.ReadAll(resp.Body)
  100. return nil, fmt.Errorf("sendMessage status %d: %s", resp.StatusCode, string(respBody))
  101. }
  102. var out struct {
  103. OK bool `json:"ok"`
  104. Result *SentMessage `json:"result"`
  105. }
  106. if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
  107. return nil, err
  108. }
  109. if !out.OK || out.Result == nil {
  110. return nil, fmt.Errorf("sendMessage not ok: %+v", out)
  111. }
  112. return out.Result, nil
  113. }
  114. // GetUpdates long-polls for new updates. Telegram holds the
  115. // connection for up to `timeoutSec` seconds. The returned slice
  116. // can be empty; the caller should advance offset to the last
  117. // update_id+1 and call again.
  118. func (c *HTTPBotClient) GetUpdates(ctx context.Context, token string, offset int64, timeoutSec int) ([]Update, error) {
  119. url := fmt.Sprintf("%s/bot%s/getUpdates", c.BaseURL, token)
  120. body, _ := json.Marshal(map[string]any{
  121. "offset": offset,
  122. "timeout": timeoutSec,
  123. "allowed_updates": []string{"message"},
  124. })
  125. req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
  126. if err != nil {
  127. return nil, err
  128. }
  129. req.Header.Set("Content-Type", "application/json")
  130. resp, err := c.HTTP.Do(req)
  131. if err != nil {
  132. return nil, err
  133. }
  134. defer resp.Body.Close()
  135. if resp.StatusCode/100 != 2 {
  136. respBody, _ := io.ReadAll(resp.Body)
  137. return nil, fmt.Errorf("getUpdates status %d: %s", resp.StatusCode, string(respBody))
  138. }
  139. var out struct {
  140. OK bool `json:"ok"`
  141. Result []Update `json:"result"`
  142. }
  143. if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
  144. return nil, err
  145. }
  146. if !out.OK {
  147. return nil, fmt.Errorf("getUpdates not ok: %+v", out)
  148. }
  149. return out.Result, nil
  150. }