219 lines
5.8 KiB
Go
219 lines
5.8 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"
|
||
"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("<")
|
||
case '>':
|
||
buf.WriteString(">")
|
||
case '&':
|
||
buf.WriteString("&")
|
||
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"`
|
||
}
|