Files
sendico/api/notification/internal/server/notificationimp/telegram/client.go
2026-01-04 12:57:40 +01:00

270 lines
8.3 KiB
Go

package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
notconfig "github.com/tech/sendico/notification/interface/services/notification/config"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"go.uber.org/zap"
)
const defaultAPIURL = "https://api.telegram.org"
type Client interface {
SendDemoRequest(ctx context.Context, request *model.DemoRequest) error
SendContactRequest(ctx context.Context, request *model.ContactRequest) error
SendCallRequest(ctx context.Context, request *model.CallRequest) error
SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error)
}
type client struct {
logger mlogger.Logger
httpClient *http.Client
apiURL string
botToken string
chatID string
threadID *int64
parseMode parseMode
}
type sendMessagePayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
ThreadID *int64 `json:"message_thread_id,omitempty"`
ReplyToMessageID *int64 `json:"reply_to_message_id,omitempty"`
DisablePreview bool `json:"disable_web_page_preview,omitempty"`
DisableNotify bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
}
func NewClient(logger mlogger.Logger, cfg *notconfig.TelegramConfig) (Client, error) {
if cfg == nil {
return nil, merrors.InvalidArgument("telegram configuration is not provided", "config.notification.telegram")
}
token := strings.TrimSpace(os.Getenv(cfg.BotTokenEnv))
if token == "" {
return nil, merrors.InvalidArgument(fmt.Sprintf("telegram bot token env %s is empty", cfg.BotTokenEnv), cfg.BotTokenEnv)
}
chatID := strings.TrimSpace(os.Getenv(cfg.ChatIDEnv))
if chatID == "" {
return nil, merrors.InvalidArgument(fmt.Sprintf("telegram chat id env %s is empty", cfg.ChatIDEnv), cfg.ChatIDEnv)
}
var threadID *int64
if env := strings.TrimSpace(cfg.ThreadIDEnv); env != "" {
raw := strings.TrimSpace(os.Getenv(env))
if raw != "" {
val, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return nil, merrors.InvalidArgumentWrap(err, fmt.Sprintf("telegram thread id env %s is invalid", env), env)
}
threadID = &val
}
}
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
apiURL := strings.TrimSpace(cfg.APIURL)
if apiURL == "" {
apiURL = defaultAPIURL
}
mode := normalizeParseMode(cfg.ParseMode)
if mode == parseModeUnset {
mode = parseModeMarkdown
}
return &client{
logger: logger.Named("telegram"),
httpClient: &http.Client{
Timeout: timeout,
},
apiURL: strings.TrimRight(apiURL, "/"),
botToken: token,
chatID: chatID,
threadID: threadID,
parseMode: mode,
}, nil
}
func (c *client) SendDemoRequest(ctx context.Context, request *model.DemoRequest) error {
if request == nil {
return merrors.InvalidArgument("demo request payload is nil", "request")
}
return c.sendForm(ctx, newDemoRequestTemplate(request))
}
type sendMessageResponse struct {
OK bool `json:"ok"`
Result *messageResponse `json:"result,omitempty"`
Description string `json:"description,omitempty"`
}
type messageResponse struct {
MessageID int64 `json:"message_id"`
Date int64 `json:"date"`
Chat messageChat `json:"chat"`
From *messageUser `json:"from,omitempty"`
Text string `json:"text"`
ReplyToMessage *messageResponse `json:"reply_to_message,omitempty"`
}
type messageChat struct {
ID int64 `json:"id"`
}
type messageUser struct {
ID int64 `json:"id"`
Username string `json:"username,omitempty"`
}
func (c *client) sendMessage(ctx context.Context, payload sendMessagePayload) (*model.TelegramMessage, error) {
body, err := json.Marshal(&payload)
if err != nil {
c.logger.Warn("Failed to marshal telegram payload", zap.Error(err))
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint(), bytes.NewReader(body))
if err != nil {
c.logger.Warn("Failed to create telegram request", zap.Error(err))
return nil, err
}
req.Header.Set("Content-Type", "application/json")
fields := []zap.Field{
zap.String("chat_id", payload.ChatID),
zap.Int("payload_size_bytes", len(body)),
zap.Bool("disable_preview", payload.DisablePreview),
zap.Bool("disable_notification", payload.DisableNotify),
zap.Bool("protect_content", payload.ProtectContent),
}
if payload.ThreadID != nil {
fields = append(fields, zap.Int64("thread_id", *payload.ThreadID))
}
if payload.ReplyToMessageID != nil {
fields = append(fields, zap.Int64("reply_to_message_id", *payload.ReplyToMessageID))
}
c.logger.Debug("Sending Telegram message", fields...)
start := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
c.logger.Warn("Telegram request failed", zap.Error(err))
return nil, err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
var parsed sendMessageResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
c.logger.Warn("Failed to decode telegram response", zap.Error(err))
return nil, err
}
if !parsed.OK || parsed.Result == nil {
msg := "telegram sendMessage response missing result"
if parsed.Description != "" {
msg = parsed.Description
}
return nil, merrors.Internal(msg)
}
c.logger.Debug("Telegram message sent", zap.Int("status_code", resp.StatusCode), zap.Duration("latency", time.Since(start)))
return toTelegramMessage(parsed.Result), nil
}
c.logger.Warn("Telegram API returned non-success status",
zap.Int("status_code", resp.StatusCode),
zap.ByteString("response_body", respBody),
zap.String("chat_id", c.chatID))
return nil, merrors.Internal(fmt.Sprintf("telegram sendMessage failed with status %d: %s", resp.StatusCode, string(respBody)))
}
func (c *client) endpoint() string {
return fmt.Sprintf("%s/bot%s/sendMessage", c.apiURL, c.botToken)
}
func (c *client) SendContactRequest(ctx context.Context, request *model.ContactRequest) error {
if request == nil {
return merrors.InvalidArgument("contact request payload is nil", "request")
}
return c.sendForm(ctx, newContactRequestTemplate(request))
}
func (c *client) SendCallRequest(ctx context.Context, request *model.CallRequest) error {
if request == nil {
return merrors.InvalidArgument("call request payload is nil", "request")
}
return c.sendForm(ctx, newCallRequestTemplate(request))
}
func (c *client) sendForm(ctx context.Context, template messageTemplate) error {
message := template.Format(c.parseMode)
payload := sendMessagePayload{
ChatID: c.chatID,
Text: message,
ParseMode: c.parseMode.String(),
ThreadID: c.threadID,
DisablePreview: true,
}
_, err := c.sendMessage(ctx, payload)
return err
}
func (c *client) SendText(ctx context.Context, chatID string, text string, replyToMessageID string) (*model.TelegramMessage, error) {
chatID = strings.TrimSpace(chatID)
if chatID == "" {
chatID = c.chatID
}
if chatID == "" {
return nil, merrors.InvalidArgument("telegram chat id is empty", "chat_id")
}
payload := sendMessagePayload{
ChatID: chatID,
Text: text,
ParseMode: c.parseMode.String(),
ThreadID: c.threadID,
DisablePreview: true,
}
if replyToMessageID != "" {
val, err := strconv.ParseInt(replyToMessageID, 10, 64)
if err != nil {
return nil, merrors.InvalidArgumentWrap(err, "invalid reply_to_message_id", "reply_to_message_id")
}
payload.ReplyToMessageID = &val
}
return c.sendMessage(ctx, payload)
}
func toTelegramMessage(msg *messageResponse) *model.TelegramMessage {
if msg == nil {
return nil
}
result := &model.TelegramMessage{
ChatID: strconv.FormatInt(msg.Chat.ID, 10),
MessageID: strconv.FormatInt(msg.MessageID, 10),
Text: msg.Text,
SentAt: msg.Date,
}
if msg.From != nil {
result.FromUserID = strconv.FormatInt(msg.From.ID, 10)
result.FromUsername = msg.From.Username
}
if msg.ReplyToMessage != nil {
result.ReplyToMessageID = strconv.FormatInt(msg.ReplyToMessage.MessageID, 10)
}
return result
}