Files
ratatoskr-go/internal/chat/telegram/telegram.go
2026-08-14 23:02:58 +05:00

198 lines
5.2 KiB
Go

// 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("&lt;")
case '>':
buf.WriteString("&gt;")
case '&':
buf.WriteString("&amp;")
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"`
}