internal/chat/telegram: long-poll Channel (sendMessage, getUpdates, Options→HTML, apiURL заменяем для тестов)
All checks were successful
build-test / build (push) Successful in 38s
All checks were successful
build-test / build (push) Successful in 38s
This commit is contained in:
198
internal/chat/telegram/telegram.go
Normal file
198
internal/chat/telegram/telegram.go
Normal file
@@ -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("<b>%d.</b> %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"`
|
||||||
|
}
|
||||||
143
internal/chat/telegram/telegram_test.go
Normal file
143
internal/chat/telegram/telegram_test.go
Normal file
@@ -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, "<b>1.</b> Чай") {
|
||||||
|
t.Errorf("формат options: %s", f.sent[0].Text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.sent[0].Text, "<b>2.</b> Кофе") {
|
||||||
|
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 ожидания входящего")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user