| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161 |
- // Package telegram is the Bot API client and command handler
- // for the broad-announce M3 milestone (SPEC §8).
- //
- // The package is split into three files:
- //
- // - client.go — BotClient interface + HTTP impl + fakes
- // - commands.go — text → Command parser
- // - handler.go — Command → DB updates
- //
- // M3 uses long-polling (cmd/telegramd). Webhook mode is M5/M9.
- //
- // All bot tokens, chat IDs, and user IDs are in the bot's
- // per-company namespace; the client takes a bot token as
- // input on every call so the same client can be reused
- // across bots in a multi-bot future.
- package telegram
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "time"
- )
- // Update is the subset of the Telegram Update object we care
- // about. We only need the message (incoming text) and the
- // update_id (offset for long-polling).
- type Update struct {
- UpdateID int64 `json:"update_id"`
- Message *Message `json:"message,omitempty"`
- }
- // Message is the subset of Message we care about.
- type Message struct {
- MessageID int64 `json:"message_id"`
- From *User `json:"from,omitempty"`
- Chat Chat `json:"chat"`
- Text string `json:"text,omitempty"`
- Date int64 `json:"date,omitempty"`
- }
- // User is the subset of User we care about.
- type User struct {
- ID int64 `json:"id"`
- IsBot bool `json:"is_bot"`
- FirstName string `json:"first_name"`
- Username string `json:"username,omitempty"`
- }
- // Chat is the subset of Chat we care about.
- type Chat struct {
- ID int64 `json:"id"`
- Type string `json:"type"` // private, group, supergroup, channel
- }
- // SentMessage is the API response from sendMessage.
- type SentMessage struct {
- MessageID int64 `json:"message_id"`
- Chat Chat `json:"chat"`
- Date int64 `json:"date"`
- Text string `json:"text"`
- }
- // BotClient is the minimal interface deliverd-telegram and
- // telegramd need. It can be swapped for a fake in tests
- // (faketgmd is a fake SERVER; this is the client-side
- // interface for swapping in process-local fakes).
- type BotClient interface {
- SendMessage(ctx context.Context, token string, chatID int64, text string) (*SentMessage, error)
- GetUpdates(ctx context.Context, token string, offset int64, timeoutSec int) ([]Update, error)
- }
- // HTTPBotClient is the real-HTTP implementation, hitting
- // https://api.telegram.org/bot<token>/...
- type HTTPBotClient struct {
- BaseURL string // override for tests; default https://api.telegram.org
- HTTP *http.Client // override for tests
- }
- // NewHTTPBotClient returns a client with sensible defaults.
- func NewHTTPBotClient() *HTTPBotClient {
- return &HTTPBotClient{
- BaseURL: "https://api.telegram.org",
- HTTP: &http.Client{Timeout: 60 * time.Second},
- }
- }
- // SendMessage posts a text message to a chat.
- func (c *HTTPBotClient) SendMessage(ctx context.Context, token string, chatID int64, text string) (*SentMessage, error) {
- url := fmt.Sprintf("%s/bot%s/sendMessage", c.BaseURL, token)
- body, _ := json.Marshal(map[string]any{
- "chat_id": chatID,
- "text": text,
- })
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := c.HTTP.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- if resp.StatusCode/100 != 2 {
- respBody, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("sendMessage status %d: %s", resp.StatusCode, string(respBody))
- }
- var out struct {
- OK bool `json:"ok"`
- Result *SentMessage `json:"result"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- return nil, err
- }
- if !out.OK || out.Result == nil {
- return nil, fmt.Errorf("sendMessage not ok: %+v", out)
- }
- return out.Result, nil
- }
- // GetUpdates long-polls for new updates. Telegram holds the
- // connection for up to `timeoutSec` seconds. The returned slice
- // can be empty; the caller should advance offset to the last
- // update_id+1 and call again.
- func (c *HTTPBotClient) GetUpdates(ctx context.Context, token string, offset int64, timeoutSec int) ([]Update, error) {
- url := fmt.Sprintf("%s/bot%s/getUpdates", c.BaseURL, token)
- body, _ := json.Marshal(map[string]any{
- "offset": offset,
- "timeout": timeoutSec,
- "allowed_updates": []string{"message"},
- })
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- resp, err := c.HTTP.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- if resp.StatusCode/100 != 2 {
- respBody, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("getUpdates status %d: %s", resp.StatusCode, string(respBody))
- }
- var out struct {
- OK bool `json:"ok"`
- Result []Update `json:"result"`
- }
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- return nil, err
- }
- if !out.OK {
- return nil, fmt.Errorf("getUpdates not ok: %+v", out)
- }
- return out.Result, nil
- }
|