Files
ratatoskr-go/internal/chat/telegram/telegram.go
Hermes 7be75fe14f
All checks were successful
CI / test (push) Successful in 54s
CI / build-and-package (amd64, darwin) (push) Successful in 45s
CI / build-and-package (amd64, linux) (push) Successful in 46s
CI / build-and-package (amd64, windows) (push) Successful in 46s
CI / build-and-package (arm64, darwin) (push) Successful in 44s
CI / build-and-package (arm64, linux) (push) Successful in 47s
fix: убран префикс tg:// из chat_id при отправке (chat not found); тесты проверяют ChatID
2026-08-16 00:19:49 +05:00

205 lines
5.5 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"
"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": 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"`
}