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