From cc180d75179924dd32f9f1878a1549e23ad565d3 Mon Sep 17 00:00:00 2001 From: Hermes Date: Fri, 14 Aug 2026 23:02:58 +0500 Subject: [PATCH] =?UTF-8?q?internal/chat/telegram:=20long-poll=20Channel?= =?UTF-8?q?=20(sendMessage,=20getUpdates,=20Options=E2=86=92HTML,=20apiURL?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D0=BC=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/chat/telegram/telegram.go | 198 ++++++++++++++++++++++++ internal/chat/telegram/telegram_test.go | 143 +++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 internal/chat/telegram/telegram.go create mode 100644 internal/chat/telegram/telegram_test.go diff --git a/internal/chat/telegram/telegram.go b/internal/chat/telegram/telegram.go new file mode 100644 index 0000000..a918d57 --- /dev/null +++ b/internal/chat/telegram/telegram.go @@ -0,0 +1,198 @@ +// 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" + "net/http" + "strconv" + "time" + + "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: + } + 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) + } + } +} + +func (ch *Channel) Send(_ context.Context, to chat.Address, m chat.Message) error { + return ch.sendMsg(string(to), 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": text[:min(len(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 m.Text + } + var buf bytes.Buffer + buf.WriteString(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() +} + +// ---- 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"` +} \ No newline at end of file diff --git a/internal/chat/telegram/telegram_test.go b/internal/chat/telegram/telegram_test.go new file mode 100644 index 0000000..679637a --- /dev/null +++ b/internal/chat/telegram/telegram_test.go @@ -0,0 +1,143 @@ +package telegram + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/kamelion/ratatoskr-go/internal/chat" +) + +// fakeTG — тестовый сервер, имитирующий Telegram Bot API. +type fakeTG struct { + srv *httptest.Server + mu sync.Mutex + sent []sendReq + updCh chan []update // следующая пачка обновлений + closed bool +} + +type sendReq struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` +} + +func newFakeTG(t *testing.T) *fakeTG { + t.Helper() + f := &fakeTG{updCh: make(chan []update, 10)} + mux := http.NewServeMux() + mux.HandleFunc("/botTOKEN/sendMessage", func(w http.ResponseWriter, r *http.Request) { + var req sendReq + json.NewDecoder(r.Body).Decode(&req) + f.mu.Lock() + f.sent = append(f.sent, req) + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + }) + mux.HandleFunc("/botTOKEN/getUpdates", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + upds := []update{} + if len(f.updCh) > 0 { + upds = <-f.updCh + } + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": upds}) + }) + + f.srv = httptest.NewServer(mux) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeTG) push(text string) { + msg := message{MessageID: int64(len(f.sent) + 1), Text: text} + msg.Chat.ID = 123 + f.updCh <- []update{{ + UpdateID: int64(len(f.sent) + 1), + Message: &msg, + }} +} + +func TestSend(t *testing.T) { + f := newFakeTG(t) + ch := New("TOKEN", time.Second) + ch.client = f.srv.Client() + // подменяем baseURL на тестовый + ch.apiURL = f.srv.URL + "/bot%s/" + + err := ch.Send(context.Background(), "tg://123", chat.Message{Text: "hello"}) + if err != nil { + t.Fatalf("Send: %v", err) + } + if len(f.sent) != 1 { + t.Fatal("не отправлено") + } + if f.sent[0].Text != "hello" { + t.Errorf("text = %q", f.sent[0].Text) + } +} + +func TestSendWithOptions(t *testing.T) { + f := newFakeTG(t) + ch := New("TOKEN", time.Second) + ch.client = f.srv.Client() + ch.apiURL = f.srv.URL + "/bot%s/" + + err := ch.Send(context.Background(), "tg://123", chat.Message{ + Text: "Выбери:", + Options: []chat.Option{ + {ID: "a", Label: "Чай"}, + {ID: "b", Label: "Кофе"}, + }, + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if !strings.Contains(f.sent[0].Text, "1. Чай") { + t.Errorf("формат options: %s", f.sent[0].Text) + } + if !strings.Contains(f.sent[0].Text, "2. Кофе") { + t.Errorf("формат options: %s", f.sent[0].Text) + } +} + +func TestIncoming(t *testing.T) { + f := newFakeTG(t) + ch := New("TOKEN", time.Second) + ch.client = f.srv.Client() + ch.apiURL = f.srv.URL + "/bot%s/" + + incoming := make(chan chat.Incoming, 1) + ch.OnMessage(func(inc chat.Incoming) { + incoming <- inc + }) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + go ch.Run(ctx) + + f.push("тест") + + select { + case inc := <-incoming: + if inc.UserID != "123" { + t.Errorf("UserID = %q, want 123", inc.UserID) + } + if inc.Address != "tg://123" { + t.Errorf("Address = %q", inc.Address) + } + if inc.Msg.Text != "тест" { + t.Errorf("text = %q", inc.Msg.Text) + } + case <-ctx.Done(): + t.Fatal("timeout ожидания входящего") + } +} \ No newline at end of file