// 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/... 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 }