// Package telegram — реализация chat.Channel через Telegram Bot API. // // Режим: long-polling (getUpdates). Формат: HTML (parse_mode). Options — // нумерованный список в тексте (inline-кнопки добавляются отдельно позже). // Секрет (токен) из config; poll-интервал тоже оттуда. package telegram import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "strconv" "strings" "time" "unicode/utf8" "github.com/kamelion/ratatoskr-go/internal/chat" ) // Channel — Telegram-канал, реализует chat.Channel. type Channel struct { token string client *http.Client pollDur time.Duration apiURL string // шаблон "https://api.telegram.org/bot%s/" (заменяемо для тестов) handler chat.Handler } // New создаёт Telegram-канал. token — Bot API токен; pollDur — интервал // long-poll (getUpdates timeout 30s, так что pollDur ≥ 30s). func New(token string, pollDur time.Duration) *Channel { return &Channel{ token: token, client: &http.Client{Timeout: 60 * time.Second}, pollDur: pollDur, apiURL: "https://api.telegram.org/bot%s/", } } // ---- chat.Channel interface ---- func (ch *Channel) OnMessage(h chat.Handler) { ch.handler = h } func (ch *Channel) Run(ctx context.Context) error { var offset int64 for { updates, err := ch.getUpdates(ctx, offset, 30) if err != nil { select { case <-ctx.Done(): return ctx.Err() default: } log.Printf("telegram: getUpdates error: %v (offset=%d)", err, offset) time.Sleep(5 * time.Second) continue } for _, upd := range updates { offset = upd.UpdateID + 1 if upd.Message == nil { continue } ch.handleUpdate(ctx, upd) } if len(updates) == 0 { time.Sleep(ch.pollDur) } } } // tgPrefix — префикс адреса чата в ratatoskr (см. handleUpdate). // В Bot API chat_id передаётся без него. const tgPrefix = "tg://" func (ch *Channel) Send(_ context.Context, to chat.Address, m chat.Message) error { return ch.sendMsg(strings.TrimPrefix(string(to), tgPrefix), formatOutgoing(m)) } func (ch *Channel) Ask(_ context.Context, to chat.Address, m chat.Message) error { // Ask реализован на уровне Router (регистрирует pending); канал только шлёт return ch.sendMsg(string(to), formatOutgoing(m)) } // Wait — нет такой операции, Close — заглушка. func (ch *Channel) Close() error { return nil } // ---- приватные методы ---- func (ch *Channel) handleUpdate(ctx context.Context, upd update) { if ch.handler == nil || upd.Message == nil { return } chatID := strconv.FormatInt(upd.Message.Chat.ID, 10) text := upd.Message.Text uid := chat.UserID(chatID) // UserID = chat_id (пока) addr := chat.Address("tg://" + chatID) ch.handler(chat.Incoming{ UserID: uid, Address: addr, Channel: ch, Msg: chat.Message{Text: text}, }) } func (ch *Channel) sendMsg(chatID, text string) error { body, _ := json.Marshal(map[string]string{ "chat_id": chatID, "text": truncateUTF8(text, 4000), "parse_mode": "HTML", }) url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token) resp, err := ch.client.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return fmt.Errorf("%w: sendMessage: %v", chat.ErrSendFailed, err) } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("%w: sendMessage HTTP %d: %s", chat.ErrSendFailed, resp.StatusCode, string(b)) } return nil } func (ch *Channel) getUpdates(ctx context.Context, offset int64, timeout int) ([]update, error) { params := fmt.Sprintf("offset=%d&timeout=%d&allowed_updates=[\"message\"]", offset, timeout) url := fmt.Sprintf(ch.apiURL+"getUpdates?%s", ch.token, params) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } resp, err := ch.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() var apiResp tgResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return nil, err } if !apiResp.OK { return nil, fmt.Errorf("telegram API error: %s", apiResp.Description) } if apiResp.Result == nil { return nil, nil } return *apiResp.Result, nil } // ---- форматирование ---- // formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options. func formatOutgoing(m chat.Message) string { if len(m.Options) == 0 { return escapeHTML(m.Text) } var buf bytes.Buffer buf.WriteString(escapeHTML(m.Text)) buf.WriteString("\n\n") for i, opt := range m.Options { buf.WriteString(fmt.Sprintf("%d. %s\n", i+1, escapeHTML(opt.Label))) } return buf.String() } func escapeHTML(s string) string { var buf bytes.Buffer for _, r := range s { switch r { case '<': buf.WriteString("<") case '>': buf.WriteString(">") case '&': buf.WriteString("&") default: buf.WriteRune(r) } } return buf.String() } // truncateUTF8 обрезает s до max байт, не разрывая UTF-8 последовательности. func truncateUTF8(s string, max int) string { if len(s) <= max { return s } s = s[:max] for len(s) > 0 && !utf8.ValidString(s) { s = s[:len(s)-1] } return s } // ---- Telegram API types ---- type tgResponse struct { OK bool `json:"ok"` Description string `json:"description,omitempty"` Result *[]update `json:"result,omitempty"` } type update struct { UpdateID int64 `json:"update_id"` Message *message `json:"message,omitempty"` } type message struct { MessageID int64 `json:"message_id"` Text string `json:"text"` Chat struct { ID int64 `json:"id"` } `json:"chat"` }