Files
ratatoskr-go/internal/chat/telegram/telegram.go
ki.sagidullin 1459670ce9
Some checks failed
CI / test (pull_request) Failing after 34s
CI / build-and-package (amd64, linux) (pull_request) Successful in 34s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s
refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram
2026-08-18 23:13:04 +05:00

219 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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("<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()
}
// 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"`
}